Build a Game Engine

Events and Error Handling

Prerequisites

Article

Error handling and event management are two topics on which you can find hundreds of articles, and you could implement 100 different ways. You can use a good old fashioned assert(), as we've been using so far, or C++ exceptions. You can use return values on all of your functions. All of these have pros and cons that we won't get into here. The short answer is there is no right answer.

That said, we do need some sort of event processing and error handling. I'll be honest and say I haven't used events and messages extensively in game programming. I've used them a lot in web and mobile development and data processing, but found less reasons to do so in games. For that reason, we'll try to keep it simple.

From a design perspective, we'll create an Event base class that other events can inherit from. Then we can track events and event listeners by their class type, and potentially an object in the game they may care about. Finally, we'll treat errors as a special type of event, and create an ErrorSystem to handle them.

Let's take a look at the Event base class:

class UENGINE_API UEvent : public UObject {
public:
    UEvent() = default;
    virtual ~UEvent() = default;
};

Nothing special here - just a base class with a virtual constructor. From this we can sub-class other types of events: KeyPressEvent, MouseMoveEvent, WindowResizeEvent, etc.

While the base class is nothing special, our design decision to use separate class types for each type of event is important. That means we want a way to know what type of class is being broadcasted, so that we can send it to the right listeners or receivers.

The C++ standard library provides a useful function called typeid, which returns a type_info. Both of these are implementation specific, so you won't really know what the values will be for a specific class type, but it does override the == operator, which means you can compare two types of classes to see if they are the same.

Your first instinct might be to create an std::vector or std::map of type_info to listeners. However, type_info cannot be used in an std::vector or std::map directly. That is because they cannot be copied. If you try to do so, you'll get a cryptic error about an std::pair referencing a deleted function. Instead, we can put our type_info into a class that initializes it (since it also can't be zero) and sets it appropriately.

From a design perspective, our EventSystem will follow a pub-sub design pattern - that means we'll have two primary functions: Subscribe, to register a new listener for certain types of messages, and Publish, to send messages to the right subscribers. This pattern obfuscates the need to know about where the message is going, or that we're sending it to all of the right places. It also reduces dependencies across areas of our code - we're simply going to publish a message and anyone who might want to receive it can do so.

Let's take a look at our EventSystem class:

/**
 * Event handling callback
 */
typedef void(*UEventHandlingCallback)(UObject* obj, UEvent* event);

class UENGINE_API UEventSystem : public USystem {
public:
    UEventSystem() = default;
    ~UEventSystem() = default;

    // A subscriber to a specific event type or events on an object (or both)
    class Subscriber {
    public:
        Subscriber() : type(typeid(this)), ptr(0), cb(0) { }
        ~Subscriber() = default;

    public:
        std::type_index type;
        UObject* ptr;
        UEventHandlingCallback cb;
    };

    /**
     * Subscribe to events
     */
    template<class T> 
    void Subscribe(UObject* obj, UEventHandlingCallback cb) {
        Subscriber* sr = new Subscriber();
        sr->type = typeid(T);
        sr->ptr = obj;
        sr->cb = cb;

        m_subscribers.push_back(sr);
    }

    /**
     * Publish an event
     */
    template<typename EventType>
    void Publish(EventType* event, UObject* ptr = 0) {
        std::type_index ix = typeid(EventType);

        auto it = m_subscribers.begin();
        for (; it != m_subscribers.end(); it++) {
            if (ix == (*it)->type) {
                if ((*it)->ptr != 0) {
                    if ((*it)->ptr == ptr) {
                        (*it)->cb((*it)->ptr, event);
                    }
                }
                else {
                    (*it)->cb((*it)->ptr, event);
                }
            }
        }
    }

protected:
    // Registered message subscribers
    std::vector<Subscriber*> m_subscribers;
};

We start with a typedef for a function pointer to an event handling callback function. We'll pass in the object the event happened on (or zero if not on an object) and the event itself.

Next up is our Subscriber class - this stores a type_index, which is a way to store a type_info, the reference object the subscriber cares about (or zero for all objects), and the callback function.

This gets used in our Subscribe function, which is a templated function that stores the typeid of the class we're subscribing to, as well as the object and callback. That means we can do things like this:

// Register to receive a callback for all error messages
GetUSystem<UEventSystem>()->Subscribe<UError>(0, ErrorCallback);

// Register to receive collision callbacks for our player object
GetUSystem<UEventSystem>()->Subscribe<UCollisionEvent>(player, CollisionCallback);

Our Subscribe function is matched by our Publish function, which takes the type_index of the first parameter to the function and iterates over our subscribers to find those that are interested in both this message type and (optionally) the object it occurred on.

While basic, this system is fairly flexible and extensible, and you can see some examples above on how we might use it.

Errors

Errors will be treated as an event. That means that we can trigger them from anywhere in the engine and they can have multiple subscribers. For example, we'll also create an ErrorSystem subscriber that will handle error logging and exiting the application if a fatal error occurs. However, if a warning message should be displayed, that may be best handled by the Window class, which can trigger an OS-specific alert or popup message.

Let's take a look at the Error class:

/**
 * Errors are a special case of event
 */
class UError : public UEvent {
public:
    UError(uint32_t level, std::string message) {
        this->level = level;
        this->message = message;
    }
    ~UError() = default;

    enum ErrorLevel {
        NONE = 0,
        DEBUG,
        INFO,
        WARN,
        FATAL
    };

public:
    uint32_t level;
    std::string message;
};

Errors will have an error level (none, debug, info, warning, or fatal), as well as a message to log or display to the user.

Now we can combine this with our EventSystem and other error handling techniques to publish an error message when something bad happens. For example, revisiting our window creation code:

m_window = glfwCreateWindow(fullscreen ? w : width, fullscreen ? h : height, title.c_str(), fullscreen ? primary : NULL, NULL);
if (m_window == NULL) {
    glfwTerminate();
    GetUSystem<UEventSystem>()->Publish(new UError(UError::FATAL, "Unable to create window.")));
    UASSERT(false, "Unable to create window.");
}

This event will now be sent to all waiting subscribers. Let's create a subscriber specifically for error handling, the ErrorSystem:

class UErrorSystem : public USystem {
public:
    UErrorSystem() : m_logFile(0) { }
    ~UErrorSystem() = default;

    /**
     * Initialize (and register error handlers)
     */
    void Initialize();

    /**
     * Open a log file to write messages to
     */
    void OpenLog(std::string filename);

    /**
     * Callback functions
     */
    static void HandleError(UObject* obj, UEvent* event);

protected:
    // Log file (if any)
    UFile* m_logFile;
};

The ErrorSystem has just 3 functions: Initialize (which will get called when the system is created) will call the Subscribe function on our EventSystem; OpenLog, which will open a log file (if we want one), and HandleError, which will be the callback supplied to our Subscribe function.

Here's the Initialize function:

void UErrorSystem::Initialize() {
    GetUSystem<UEventSystem>()->Subscribe<UError>(0, HandleError);
}

As you can see, we're calling our Service Locator pattern function GetUSystem to find the EventSystem, and asking for the HandleError function to be called for all UError messages. This class will take care of logging any error messages we throw, so next we'll look at OpenLog:

void UErrorSystem::OpenLog(std::string filename) {
    // Close an already open log file
    if (m_logFile) {
        m_logFile->Close();
        delete m_logFile;
    }

    // Open the new log file
    m_logFile = new UFile();
    m_logFile->Open(filename, "a");
}

Finally, let's look at how we actually handle errors, the meat of the ErrorSystem:

void UErrorSystem::HandleError(UObject* obj, UEvent* evnt) {
    // Get instance of object
    UErrorSystem* errorSystem = GetUSystem<UErrorSystem>();
    UError* error = (UError*)evnt;

    if (errorSystem->m_logFile) {
        // Write to log file, start by getting a date and time
        struct tm timestamp;
        GetUSystem<UTimeSystem>()->DateTimeLocal(&timestamp);

        // Convert error level to error string
        std::string errorType = "";

        switch (error->level) {
        case UError::DEBUG:
            errorType = "DEBUG";
            break;
        case UError::INFO:
            errorType = "INFO ";
            break;
        case UError::WARN:
            errorType = "WARN ";
            break;
        case UError::FATAL:
            errorType = "FATAL";
            break;
        default: break;
        }

        // Create a string to log
        char* buffer = (char*)malloc(error->message.length() + 40);
        int bufferSize = sprintf(buffer, "[%s] [%.2d/%.2d/%.4d %.2d:%.2d:%.2d]: %s\r\n",
            errorType.c_str(),
            timestamp.tm_mon + 1,
            timestamp.tm_mday,
            timestamp.tm_year + 1900,
            timestamp.tm_hour,
            timestamp.tm_min,
            timestamp.tm_sec,
            error->message.c_str()
        );

        errorSystem->m_logFile->Write((unsigned char*)buffer, bufferSize);
        free(buffer);
    }

    // Handle error as needed
    if (error->level == UError::FATAL) {
        errorSystem->m_logFile->Close();
        exit(0);
    }
}

Because this function is static, the first thing we do is find the instance of our system. Second, we re-cast the UEvent as a UError, since we know we're only registered to receive callbacks for UError messages.

If our log file is open, we craft a log message - that includes the level of the error, and a date and timestamp (in local time) of when the error occurred. This will be useful later to have users send in if they are encountering issues with our games.

Finally, for fatal errors, we call the exit() function, which fortunately exists on all platforms.

Next Steps

We have now created a base for publishing and subscribing to events, and put it to use to publish and subscribe to error messages. A typical extension of the EventSystem class would be to set a minimum logging level based on some user setting and not log anything below that. That would allow you (the engine developer) and the game developers to log a lot of debug information, but then set a reasonable default (say INFO or WARN level) in the final shipped version. I will leave that as an exercise for the reader.