Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 66

Collision Event System

Recommended resources

Introduction

Hello! In our last lesson, you dramatically improved the performance of our collision detection by implementing a uniform grid. This spatial partitioning technique is a classic broad-phase optimization that prevents the engine from wasting time on O(N^2) checks. Your collision_system_update function is now capable of efficiently finding colliding pairs even in a large scene and returning them in an array.

However, just knowing a collision happened isn't enough. How does the game react? Do the objects bounce? Does one of them take damage? Does a sound effect play? If we put all that logic inside the collision system, it would become a tangled mess, violating the principle of separation of concerns.

This lesson is all about solving that problem. You will create a collision event system that notifies other parts of the game when a collision occurs. This will decouple the detection of collisions from the reaction to them, allowing for a much cleaner and more scalable engine architecture. We will implement a classic Publisher-Subscriber pattern using C's function pointers, a concept you were introduced to early in this course.


Why Decoupling Matters: The Publisher-Subscriber Pattern

The core idea is simple: the collision system's only job is to detect collisions. When it finds one, it "publishes" an event. Other systems, like physics, audio, or gameplay logic, can "subscribe" to these events and react accordingly. The collision system doesn't need to know or care what the subscribers do.

Let's start with a video that clearly explains this concept.

Custom Event System in my C++ ECS game engine

The video 'Custom Event System in my C++ ECS game engine' by FuniTinker provides an excellent high-level overview of why event systems are so valuable in game engines and introduces the core concepts we'll be using.

Watch from the beginning to 02:38. Pay close attention to the collision example and the distinction between 'immediate' events and an 'event queue'. We will be implementing an event queue.

As the video explains, this approach promotes two key software design principles:

  • Responsibility: The collision system is responsible for detecting, not reacting. Gameplay code is responsible for reacting.
  • Decoupling: The engine's core physics systems can be reused for any game because they are not tied to specific gameplay logic (like playing sounds or reducing health).

Architectural Design: Events, Queues, and Callbacks

There are a few ways a low-level system can notify a high-level one. Let's briefly review the options.

Managing Decoupling Part 2: Polling, Callbacks and Events

The article 'Managing Decoupling Part 2: Polling, Callbacks and Events' from GameDev.net gives a professional perspective on the main notification techniques. We'll focus on its discussion of callbacks and event queues.

Read the sections 'Polling', 'Callbacks', and 'Events'. Notice why polling is a poor fit for collision detection. Pay special attention to the 'Callbacks' section, which advocates for queuing callbacks and implementing them with C-style function pointers—this is precisely the model we will follow.

Based on these resources, our design will be a queued callback system:

  1. Publisher: The collision system will publish events.
  2. Event: A CollisionEvent struct will hold data about the collision (i.e., the two entities involved).
  3. Event Queue: A simple array will store all the CollisionEvents generated in one frame.
  4. Event Manager: A central manager will hold the queue and a list of subscribers.
  5. Subscribers (Handlers): These will be C functions that we register with the event manager. They will be notified when events are processed.

The overall flow looks like this:

Event System Sequence Diagram
This sequence diagram shows the relationship between a Publisher (our collision system), a Handler/Subscriber (our reaction logic), and the Event Manager that coordinates them. Notice how the Manager queues events and dispatches them to subscribed handlers.

Implementing Callbacks with Function Pointers in C

You have some familiarity with C# events and delegates. In C, we achieve a similar result by manually managing pointers to functions. Since you covered function pointers in the first module, this is a perfect opportunity to put that knowledge to practical use.

Let's watch a video that recaps the syntax and usage of function pointers for creating callbacks in C.

Callback Functions in C Explained

The video 'Callback Functions in C Explained' by Codeflash provides a clear and direct tutorial on the C-specific mechanics of function pointers, which are the foundation of our event system.

Watch from the beginning to 06:58. This covers the definition of a callback, the syntax of a function pointer, a simple practical example, and how to use typedef to make the syntax much cleaner.

Now, let's apply this to build our event system. We'll create event_system.h and event_system.c.

1. Defining the Structures (event_system.h)

First, we define the event itself and the function pointer type for our subscribers. Using typedef makes the code much more readable, as you saw in the video.

// event_system.h




#ifndef EVENT_SYSTEM_H
#define EVENT_SYSTEM_H




#define MAX_SUBSCRIBERS 16
#define MAX_EVENT_QUEUE 1024

// The data payload for a collision event
typedef struct {
    int entity_a;
    int entity_b;
} CollisionEvent;

// Define a function pointer type for a collision event subscriber (callback).
// Any function that matches this signature can subscribe to collision events.
// It takes a pointer to the event data as an argument.
typedef void (*CollisionCallback)(const CollisionEvent* event);

// --- Public API for the Event System ---

// Subscribes a callback function to be called when collision events are dispatched.
void event_system_subscribe_collision(CollisionCallback callback);

// Adds a collision event to the queue. This is our "publish" function.
void event_system_publish_collision(int entity_a, int entity_b);

// Processes all events in the queue, calling all subscribers for each event.
void event_system_dispatch_collisions();




#endif // EVENT_SYSTEM_H

2. Implementing the Event Manager (event_system.c)

Next, we implement the logic. The "manager" itself isn't a complex struct; it's just a set of static global variables and the functions that operate on them. This is a common pattern in C for representing a global singleton system.

// event_system.c




#include "event_system.h"
#include <stdio.h> // For NULL

// --- Internal state of the event manager ---

// An array to hold all subscribed callback functions
static CollisionCallback s_subscribers[MAX_SUBSCRIBERS];
static int s_subscriber_count = 0;

// An array to act as our event queue
static CollisionEvent s_event_queue[MAX_EVENT_QUEUE];
static int s_event_queue_count = 0;


// --- Implementation of the Public API ---

void event_system_subscribe_collision(CollisionCallback callback) {
    if (s_subscriber_count < MAX_SUBSCRIBERS) {
        s_subscribers[s_subscriber_count] = callback;
        s_subscriber_count++;
    }
    // In a real engine, you'd log an error if you exceed MAX_SUBSCRIBERS
}

void event_system_publish_collision(int entity_a, int entity_b) {
    if (s_event_queue_count < MAX_EVENT_QUEUE) {
        s_event_queue[s_event_queue_count].entity_a = entity_a;
        s_event_queue[s_event_queue_count].entity_b = entity_b;
        s_event_queue_count++;
    }
    // In a real engine, you'd log an error if you exceed MAX_EVENT_QUEUE
}

void event_system_dispatch_collisions() {
    // For every event in the queue...
    for (int i = 0; i < s_event_queue_count; ++i) {
        // ...call every subscribed callback function.
        for (int j = 0; j < s_subscriber_count; ++j) {
            if (s_subscribers[j] != NULL) {
                s_subscribers[j](&s_event_queue[i]);
            }
        }
    }

    // Clear the queue for the next frame
    s_event_queue_count = 0;
}

Integrating with the Main Game Loop

Now we can integrate this system into our application. The flow inside the main loop will be:

  1. Run the collision system to get a raw list of collisions.
  2. Iterate the raw list and publish an event for each collision.
  3. Dispatch all queued events, which triggers the subscriber functions.

This flow separates the detection phase from the reaction phase within the game loop.

Game Loop with Collision Callbacks
This diagram shows how collision detection (our `collision_system_update`) fits into the main loop. It generates events/callbacks that feed back into the 'Game Logic', allowing the game to react.

Let's modify main.c to use this new system.

// In main.c



#include <stdio.h>
#include <stdlib.h>
// ... other includes



#include "collision_system.h"
#include "grid.h"
#include "event_system.h" // Include our new system

// --- Example Subscriber Functions ---

// A simple subscriber that just logs the collision to the console.
void on_collision_log(const CollisionEvent* event) {
    printf("LOG: Collision detected between Entity %d and Entity %d.\n",
           event->entity_a, event->entity_b);
}

// A stub for a subscriber that would handle physics responses.
void on_collision_physics_response(const CollisionEvent* event) {
    // In the future, this would apply impulse, etc.
    // For now, we just print a message.
    printf("PHYSICS: Responding to collision between %d and %d.\n",
           event->entity_a, event->entity_b);
}

// A stub for a subscriber that would play a sound.
void on_collision_play_sound(const CollisionEvent* event) {
    printf("AUDIO: Playing impact sound for collision between %d and %d.\n",
           event->entity_a, event->entity_b);
}

int main(void) {
    World world;
    world_init(&world);
    
    Grid grid;
    grid_create(&grid, (vec3){-50.0f, -50.0f, -50.0f}, 100.0f, 5.0f);

    // --- Subscribe our functions to the event system ---
    printf("Subscribing event handlers...\n");
    event_system_subscribe_collision(on_collision_log);
    event_system_subscribe_collision(on_collision_physics_response);
    event_system_subscribe_collision(on_collision_play_sound);
    printf("----------------------------------------\n\n");

    // Add some entities that are guaranteed to collide
    int e1 = world_create_entity(&world);
    entity_add_transform(&world, e1, (Transform){{1.0f, 1.0f, 1.0f}});
    entity_add_collider_sphere(&world, e1, (Sphere){{0.0f, 0.0f, 0.0f}, 0.5f});

    int e2 = world_create_entity(&world);
    entity_add_transform(&world, e2, (Transform){{1.2f, 1.2f, 1.2f}});
    entity_add_collider_sphere(&world, e2, (Sphere){{0.0f, 0.0f, 0.0f}, 0.5f});
    
    // --- Main Loop Simulation ---
    
    // 1. Detect collisions
    Collision collision_list[1024];
    int num_collisions = collision_system_update(&world, &grid, collision_list, 1024);

    // 2. Publish events from the collision list
    printf("Publishing %d collision events...\n", num_collisions);
    for (int i = 0; i < num_collisions; ++i) {
        event_system_publish_collision(collision_list[i].entity_a, collision_list[i].entity_b);
    }
    printf("\n");

    // 3. Dispatch events to subscribers
    printf("Dispatching events...\n");
    printf("----------------------------------------\n");
    event_system_dispatch_collisions();
    printf("----------------------------------------\n");

    grid_destroy(&grid);
    return 0;
}

When you compile and run this, you will see the output from each subscribed function, even though the collision_system itself knows nothing about logging, physics, or audio. You have successfully decoupled the systems!


Conclusion

In this lesson, you have architected and implemented a fundamental piece of any scalable game engine: an event system. You moved beyond simple detection and created a mechanism for the rest of the engine to react to collisions in a clean, decoupled way.

Key Takeaways:

  • Decoupling is crucial for writing maintainable and reusable engine code. The collision detector should not be responsible for game logic.
  • The Publisher-Subscriber pattern is an effective way to achieve this decoupling.
  • An event queue is preferable to immediate callbacks for game logic and physics, as it prevents state modification during critical loops and provides a clear point in the frame for handling all events.
  • In C, this pattern can be implemented powerfully and efficiently using function pointers as callbacks. typedef is essential for keeping the syntax clean.

Preview of the Next Lesson:
Our on_collision_physics_response function is currently just a placeholder. To make it real, we need an actual physics simulation. In the next module, you will begin building a simple physics engine. You will learn to integrate velocity and acceleration into the fixed update loop using Euler integration and apply forces, like gravity, to entities. The collision events you're now dispatching will become the trigger for collision response logic, making your game world feel solid and interactive.