3
\$\begingroup\$

Since writing your own C++ game engine seems to be really popular these days (seriously just look at the amount of people presenting their WIPs on YouTube) I figured I'd try it myself.

My mental model of an event system looks like this:

  • Events are basically signals that tell you that something has happened. Certain types of events may also hold additional information about some state in the form of member variables. However events do not act. They are just information that is passed around.
  • All classes that want to partake in the event system need to implement an EventHandler interface.
  • EventHandlers are responsible for dispatching, receiving/storing and processing events.
    • Each instance of an EventHandler holds a list of references to other EventHandlers. These other handlers receive it's broadcasted events.
    • When a handler receives an event it stores the event in a queue, so processing can be scheduled.
    • Each implementation of the EventHandler interface react differently to events. Different types of events may need to be addressed differently.
  • The "user" of the engine is free to define all types of Events and EventHandlers (i.e. implementations of them).

Here is my current approach that "works" (I am positive that it's horrible, since the user has to trial-and-error dynamic_cast the event):

  1. The "engine" side of the event system:
/**
 * Engine code
 */

// Event.hpp/cpp
class IEvent
{
    /* Event interface */
protected:
    virtual ~IEvent() = default;
};

// EventHandler.hpp/cpp
class IEventHandler
{
public:
    // Send events to other handlers
    void dispatch_event(const IEvent* event)
    {
        for (auto& recipient : event_recipients)
        {
            recipient->event_queue.push(event);
        }
    }
    // Invoke processing for events in queue when the time has come (oversimplified)
    void process_event_queue()
    {
        while (!event_queue.empty())
        {
            event_callback(event_queue.front());
            event_queue.pop();
        }
    }
    // Push to queue manually
    void push_queue(const IEvent* event)
    {
        event_queue.push(event);
    }

protected:
    // Store events so their processing can be scheduled
    std::queue<const IEvent*> event_queue;
    // Who will receive event dispatches from this handler
    std::set<IEventHandler*> event_recipients;
    // Process each individual event
    virtual void event_callback(const IEvent* event) = 0;
};
  1. How the "user" might typically interact with it:
/**
 * "User" code
 */

// UserEvents.hpp/cpp
class UserEventA : public IEvent {};
class UserEventB : public IEvent {};
class UserEventC : public IEvent {};

// UserEventHandler.hpp/cpp
class UserEventHandler : public IEventHandler
{
protected:
    // AFAIK this is painfully slow
    void event_callback(const IEvent* event) override
    {
        if (auto cast_event = dynamic_cast<const UserEventA*>(event))
        {
            cout << "A event" << endl;
        }
        else if (auto cast_event = dynamic_cast<const UserEventB*>(event))
        {
            cout << "B event" << endl;
        }
        else
        {
            cout << "Unknown event" << endl;
        }
    }
};

int main()
{
    // Create instances of user defined events
    UserEventA a;
    UserEventB b;
    UserEventC c;

    // Instance of user defined handler
    UserEventHandler handler;

    // Push events into handlers event queue
    handler.push_queue(&a);
    handler.push_queue(&b);
    handler.push_queue(&c);

    // Process events
    handler.process_event_queue();
}

Some alternatives that I've already explored but didn't lead me anywhere:

  1. The visitor pattern (utilizing double dispatch) seems like a good idea but only accounts for the "visitables" to be extendable. IIRC the "visitors" usually have a rigidly defined interface. Here however both the Events and the EventHandlers are subject to change and thus I don't think the visitor pattern can be applied.
  2. Replacing the IEvent* in the event_queue of the EventHandler interface with std::variant would enable me to use the comparatively fast std::get_if instead of costly dynamic_casts. Every implementation would know what event types it can process. However this would make dispatching events between different implementations that accept different event types impossible, due to their variants (and thus their queues) being structured differently.
\$\endgroup\$
5
  • 1
    \$\begingroup\$ Welcome to CodeReview! Could you please elaborate why do you think it's horrible? \$\endgroup\$ Commented Apr 9, 2021 at 6:21
  • \$\begingroup\$ I think your approach is similar to QEvent from Qt framework. Tough QEvent has more methods than your IEvent. One of them holds event type and is used for casting. More info can be found here and here. Source code for place where event loop work begins in Qt application can be found here \$\endgroup\$
    – xevepisis
    Commented Apr 9, 2021 at 8:23
  • \$\begingroup\$ @PeterCsala Sorry, I should have been more precise! I think this approach is awful since the user my "engine" would have to perform all these dynamic_casts in order to determine the event type, which is probably not suited for a performance crirtical real-time application like a game engine. \$\endgroup\$ Commented Apr 9, 2021 at 15:22
  • \$\begingroup\$ @xevepisis The problem with Qt's approach is that they pre-define a list of possible event types (specified in QEvent::Type) that you have to adhere to. This approach seems unsuitable for a game engine where the game designer might come up with the most absurd and specific event types. \$\endgroup\$ Commented Apr 9, 2021 at 17:47
  • \$\begingroup\$ @TheBeautifulOrc There is enum member QEvent::User with value 1000, so if you want to add your custom events you just start your own enum from QEvent::User and subclass QEvent. Maybe I am missing something tough, never did it myself. Also there is helper function int QEvent::registerEventType(int hint = -1) \$\endgroup\$
    – xevepisis
    Commented Apr 9, 2021 at 17:59

2 Answers 2

3
\$\begingroup\$

Why?

What's it actually for? What sort of "event" are we dealing with, and why do we need to delay dealing with that event, instead of just calling a function directly?

Why do we need event type erasure, instead of dealing with specific event types, e.g. IEventHandler<T> implementing void event_callback(T const& e)?

This is all quite abstract. So it's hard to tell if dispatching events like this is appropriate instead of something more like delegates or signals.

(I'm not saying the design is necessarily invalid, but we'd need some concrete examples of what it's actually being used for in a game).


Separate dispatch and handling

I think it's more usual to separate the dispatching of events and the receiving of events.

Right now a class that only needs to dispatch events has an unnecessary queue of events to process, and a function to process them.

A class that only needs to receive events also has an unnecessary list of recipients.

So a separate IEventHandler and IEventDispatcher would probably be a good idea.


Interface and access control

std::queue<const IEvent*> event_queue;
std::set<IEventHandler*> event_recipients;

Making these protected is a little dangerous. It would be better for the base class to implement a more complete interface (e.g. dispatcher.add_recipient(&foo_object);), and then make these variables private.


Too many queues

Note that giving each event recipient its own event queue might not be a good idea. With a 100 listeners to an event (not unreasonable, depending on what this is used for), dispatching an event involves pushing it to 100 different queues.

It might be better to keep the event queue on the dispatch side, and have the dispatcher call a process_event function on each recipient instead.


\$\endgroup\$
4
  • \$\begingroup\$ I'll try to address your points one by one: 1. Why?: As I said, these events act as general purpose notifications for engine or game components to react to. They can range from something as general as keyboard input or the application window closing to something as specific as a weapon being fired in a game. I do not know which EventHandler would want to react to which and how many types of events. The reason I'm buffering the events instead of processing them directly is because in order to keep performance of my game engine steady, processes might need to be scheduled. \$\endgroup\$ Commented Apr 9, 2021 at 17:36
  • \$\begingroup\$ 2. Interface and access controll: You're absolutely right. 3. Separate dispatch/handling & too many queues: The approach you're suggesting is quite interesting and I'll reconsider it down the road. However one "dispatcher queue" might make the entire idea of scheduling event processing difficult and does not solve the problem I'm currently facing. Also the optimization of reducing the number of queues (mind that they only hold pointers to my events, there is no costly copying here) seems really low-level and is propably not the most pressing issue with my current code. \$\endgroup\$ Commented Apr 9, 2021 at 17:43
  • 2
    \$\begingroup\$ Nice answer, but I’m surprised no-one is commenting on the lifetime management issues. It seems a little barmy that the event queue doesn’t take ownership of the event object, like, via a smart pointer. So if I want to make an event, I have to create the event object… and hold onto it for an indefinite amount of time until the event handler gets around to processing it, which I’ll somehow have to find out about (how?), and only then delete it? (The example code given, where the events have to outlive the event handler to hide this problem, is absurd.) \$\endgroup\$
    – indi
    Commented Apr 10, 2021 at 4:00
  • \$\begingroup\$ @indi Ouch, that one was obvious... Thanks for the reminder! \$\endgroup\$ Commented Apr 10, 2021 at 15:57
2
\$\begingroup\$

It is not a healthy idea for a game engine to deal this way with general events.

The class IEvent is not particularly useful. Dynamic cast operation is a rather heavy operation and it isn't healthy to use it for something as basic as mouse click or keyboard click.

Just think of it. You'll have hundreds of events and hundreds of potential clients for the events and each will have to perform a bunch of dynamic casts to even figure out if the event is even relevant. And probably half the time they will do the same casts over and over again.

Consider trying the data oriented design instead of object oriented design. Here is a link from cppcon explaining it vs OOP

https://www.youtube.com/watch?v=yy8jQgmhbAU&ab_channel=CppCon

I also answer to some comments from another answer:

  1. Why?: As I said, these events act as general purpose notifications for engine or game components to react to. They can range from something as general as keyboard input or the application window closing to something as specific as a weapon being fired in a game. I do not know which EventHandler would want to react to which and how many types of events. The reason I'm buffering the events instead of processing them directly is because in order to keep performance of my game engine steady, processes might need to be scheduled.

I believe a more healthy approach is a subscription model. Where certain event handlers subscribe to certain types of events. For instance, you classify events in several broad categories and let event handlers listen only to events of certain general categories they are interested it.

Second, let event handlers decide whether they want to process the event immediately or push into a processing queue. If dealing with the event is quick enough there might be no need in scheduling it to a later time at all. For example, to handle it might simply performing more finetuned tests on the event and then deciding when to schedule it and where or perhaps drop it completely - since the initial categorization is probably rather broad you might need additional filtering.

\$\endgroup\$

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.