Game Development

Why I Built My Own Event Manager for Unreal Engine in C++

Since I first encountered Event Managers from the book Game Coding Complete during my studies, they have become an important tool for a majority of my game development projects and also influenced the implementation of my web projects and network applications that I developed.

Advantages of Your Own Event Manager

So why are Event Managers so crucial? Well, without them, you'd be drowning in a sea of direct calls and dependencies. Your code would quickly become an unwieldy monolith that's hard to understand, maintain, and expand. Event Managers enable loose coupling between components, meaning your game objects, network services, or UI elements can operate independently. They only need to know they must send and receive messages, not how other parts of the code work or who's on the receiving end.

Setting Game Instance in Project Settings

In the world of game development, Event Managers are used to handle a variety of tasks: from processing player inputs to updating game status, to managing complex scenarios where multiple objects must respond to an event simultaneously. Imagine a boss fight where defeating the boss triggers a chain of events — explosions, doors opening, rewards appearing. Without Event Managers, coordinating these actions would be a nightmare, as all systems that need to interact must also know about each other, requiring references. What if the Unreal Engine's Garbage Manager decides to destroy one of the objects, making the references invalid? Resulting errors are hard to find and frustrating. It's better if the Event Broadcaster and Receiver don't need to know each other. The event "Boss Enemy Died" is then received by other game systems interested in that event. The door can listen for the event and open as soon as the boss dies, meaning the boss doesn't need to know the door exists.

Setting Game Instance in Project Settings

In network applications, they enable the processing of messages from servers and other clients, making complex, distributed systems work efficiently and reliably. A network interface can listen for events, serialize them, and send them across the network to connected computers. These can then deserialize the events and trigger them locally as if they were triggered by their own system. All classes interested in the event can then respond. This is how I've implemented all my multiplayer games. User input from the network is no different from local input. Network communication between computers also goes through the Event System.

Unreal Engine already has its own Event Manager and messaging system. So why build my own? The most obvious reason is dependency on the Unreal Engine infrastructure. Sure, it's powerful and well-developed, but what if I have a singleton in my application that's not a UObject? Unreal Engine relies heavily on UObjects for most of their systems, including event management. That's great as long as you stay within the ecosystem they've created for you. But what if you want to break out and go your own way?

Imagine you have a powerful class at the core of your system that's not based on UObject. In the Unreal Engine world, you'll quickly hit limits when trying to integrate it into the event infrastructure. This is where your own Event Manager comes in. By creating your own, you can build a system completely independent of UObject restrictions, giving you the flexibility to design your architecture as you see fit.

Decoupling from Unreal infrastructure not only gives you more control over your design but also makes your code more portable and reusable. You're no longer tied to Unreal Engine; your event system can be reused in other contexts or projects, perhaps even those using a completely different technology. This is especially valuable in an industry that's constantly evolving, where the ability to quickly adapt and iterate often makes the difference between success and stagnation.

Moreover, building your own Event Manager allows for tailored optimization. You can address performance bottlenecks and implement features specifically suited to the needs of your project. Maybe you need ultra-fast event processing for a high-frequency trading system, delayed execution, asynchronous background event processing, or a particular type of error handling that Unreal Engine simply doesn't offer.

So, while the Unreal Engine and its event system are more than sufficient for many projects, there are scenarios where you need more flexibility and control. In such cases, building your own Event Manager isn't just an option; it's a necessity.

Implementation of the Event Manager

First, I want to provide a rough overview of the system. What are the subsystems that make up an Event System? Essentially, an Event System consists of 3 parts:

  • The Event
  • The Event Listener
  • The Event Manager

Events are the messages that are sent across the system. They contain the type of the event that is triggered and can hold parameters that can be sent along with the event. For example, if the Player Character is hit by an enemy and loses HP, you'd want to update the HP display on the HUD and reduce the HP bar slightly. The event that would be sent could be a PlayerHealthUpdatedEvent. This event might then have two parameters: OldHealth and NewHealth.

The Event Listener is any class that wants to listen for a specific event. The UI could implement an Event Listener that listens for the PlayerHealthUpdatedEvent. Every time the event is fired somewhere, it triggers at the Event Listener. For each event, there is a separate function that is called when an event of that type is fired. This way, the HUD can react to the player taking damage and update the displayed health bar to the new value.

The Event Manager is a central point that every system in the game can access and with which events can be triggered. For instance, the Player Controller might respond to being hit by an enemy and taking damage by triggering a PlayerHealthUpdatedEvent through the Event Manager. The Player Controller doesn't care who listens to the PlayerHealthUpdatedEvent. It just fires off the event, and anyone interested can register a listener for the event and then receive and respond to it.

The Event

Events themselves derive from an interface and implement their own logic. As can be seen, both a serialize and a deserialize function are implemented. These functions are used to convert messages into strings so that they can be sent over the network. All data types that are to be sent over the network must be able to be serialized into a string format and then deserialized again. For example, you can format it as JSON or XML, send it as a string over the network, and then reassemble it into an event on the receiver's side. I decided to write the values one after the other, separated by the delimiter "#|#", into a string. Nothing overly complicated. Since I don't know which class will derive from the interface and what parameters should be sent with the event, there's a pure virtual function serializeBiDirectional that must be defined by the class implementing the interface.

#pragma once

#include "CoreMinimal.h"
#include "Identifier.h"

class IEvent;
typedef TSharedPtr<IEvent> EventPtr;

/// baseclass for an event
class EVENTSYSTEM_API IEvent
{
public:
    /// returns the unique type identifier of this eventtype
    virtual HashValue getHash() const = 0;
    virtual FString getName() const = 0;


    /// serializes the data
    /// \param stream	stream to that all data will be written
    void serialize(FString& out_data)
    {
        HashValue hash = getHash();
        out_data.Append(FString::FromInt(hash));		// write type hash value
        out_data.Append("#|#");
        serializeBiDirectional(out_data, true);
        out_data.Append("#|#");
    }

    /// creates a new event from binary data
    /// \param stream	stream from that the data is taken
    /// \return pointer to a new event
    static EventPtr deserialize(const FString& data)
    {
        int32 found = data.Find("#|#");
        if (found != INDEX_NONE)
        {
            FString hash_string = data.Mid(0, found);

            // read hash/ident
            HashValue hash = FCString::Atoi(*hash_string);

            // find fabric
            auto hashFabricIt = getEventFabricList().Find(hash);

            if (hashFabricIt != nullptr)
            {
                // create event and deserialize by calling the fabric function
                EventPtr pNewEvent = (*hashFabricIt)();

                FString remaining_string = data.Mid(found + 3);

                pNewEvent->serializeBiDirectional(remaining_string, false);

                return pNewEvent;
            }
            else
            {
                return EventPtr();
            }
        }
        else
        {
            return EventPtr();
        }
    }

    /// registers an event for deserialization
    static void registerEventForDeserialization(const HashValue hash, const TFunction<EventPtr()>& fabricMethod)
    {
        getEventFabricList().Add(TPair<HashValue, TFunction<EventPtr()>>(hash, fabricMethod));
    }

protected:
    IEvent() {}
    virtual ~IEvent() {}

    /// \brief bidirectional serialization (read and write)
    /// \remarks you have to overwrite this method AND call there the parent function first
    virtual void serializeBiDirectional(FString& data, bool write) = 0;

    static TMap<HashValue, TFunction<EventPtr()>>& getEventFabricList()
    {
        static TMap<HashValue, TFunction<EventPtr()>> var;
        return var;
    }
};

/// Macro for event-registration
/// \param eventName	type name of the new event
/// \remarks call this in the cpp of every new event
#define REGISTER_EVENT(eventName) \
    class _EventRegisterClass##eventName \
    { \
    public: \
        _EventRegisterClass##eventName() \
        { \
            eventName evt; \
            IEvent::registerEventForDeserialization(evt.getHash(), []{ return EventPtr(new eventName); }); \
        } \
    }; const _EventRegisterClass##eventName _EventRegisterStartUpObject##eventName;

What also stands out is the HashValue. To ensure each event is identifiable, a unique integer is determined for each event. Since integers can be compared faster than strings, it makes sense to identify the event type via a HashValue.

#pragma once

#include "CoreMinimal.h"

typedef uint32 HashValue;

/// \brief a hashed text identifier
/// the hash is presumed to be collision free
/// \remarks if there however is a collision, this has to be assured by the user. The hash is 32bit enforced 
class EVENTSYSTEM_API Identifier
{
public:
    /// creates an identifier
    Identifier() {};

    /// creates an identifier from a string
    /// \param name		identifier name; case will be ignored!
    Identifier(const FString& name);

    /// copy constructor
    Identifier(const Identifier& cpy);

    /// creates an identifier from hash only
    /// \attention the name will bei invalid! only use if the original name is lost
    explicit Identifier(HashValue hash, const FString& identifier = "____unkown identifier name___");

    /// returns clear text name
    FString getName() const { return m_name; }

    /// returns hash-value
    unsigned int getHash() const { return m_hash; }

    /// equality operator
    /// \attention only checks the hash!
    bool operator==(const Identifier& left) const { return m_hash == left.m_hash; }

    /// comparision operator
    /// \attention only checks the hash!
    bool operator<(const Identifier& left) const { return m_hash < left.m_hash; }

    /// typecast to size_t for the unordered containers
    operator size_t() const { return m_hash; }

private:
    FString			m_name;
    HashValue		m_hash;
};

FORCEINLINE uint32 GetTypeHash(const Identifier& id)
{
    return id.getHash();
}

Let's look at an implementation of the IEvent interface. I'll take the example I mentioned above:

#pragma once

#include "CoreMinimal.h"
#include "Identifier.h"
#include "IEvent.h"

class PlayerHealthUpdatedEvent : public IEvent
{
public:
    PlayerHealthUpdatedEvent(int oldValue, int newValue)
    {
        m_oldValue = oldValue;
        m_newValue = newValue;
    }
    PlayerHealthUpdatedEvent() {}
    ~PlayerHealthUpdatedEvent() {}

    static const unsigned int Identity = 515189911;
    HashValue getHash() const override
    {
        return Identity;
    }

    FString getName() const override
    {
        return FString("PlayerHealthUpdatedEvent");
    }

    int getOldValue() const
    {
        return m_oldValue;
    }

    int getNewValue() const
    {
        return m_newValue;
    }

private:
    virtual void serializeBiDirectional(FString& data, bool write) override
    {
        if (!write)
        {
            int32 found = data.Find("#|#");
            if (found != INDEX_NONE)
            {
                m_oldValue = FCString::Atoi(*data.Mid(0, found));
                data = data.Mid(found + 3);
            }

            int32 found2 = data.Find("#|#");
            if (found2 != INDEX_NONE)
            {
                m_newValue = FCString::Atoi(*data.Mid(0, found2));
                data = data.Mid(found2 + 3);
            }
        }
        else
        {
            data.Append(FString::FromInt(m_oldValue));
            data.Append("#|#");
            data.Append(FString::FromInt(m_newValue));
            data.Append("#|#");
        }
    }

    int m_oldValue;
    int m_newValue;
};

REGISTER_EVENT(PlayerHealthUpdatedEvent);

For static const unsigned int Identity = 515189911; I simply used the Google Random Number Generator. The number itself doesn't matter as long as it's unique across the entire project. As you can see, I've implemented serializeBiDirectional. If you're not making a network game or the event doesn't need to be sent over the network, you can completely neglect the implementation, as it will never be called.

The Event Manager

The Event Manager is a singleton that can be used by every controller and class in the project to register event listeners or to trigger events.

First, I want to discuss the two functions queueEvent(const EventPtr& pEvent) and triggerEvent(const EventPtr& pEvent). Both functions are there to trigger events that will then be received by Event Listeners. The difference is that queueEvent does not trigger events immediately but does so asynchronously. The events are appended to the queuedEvents array and processed at a later time. Meanwhile, the rest of the code continues to run. I prefer this function so that the call of the function triggering the event is not hindered and does not have to wait for the potentially numerous or complex listeners to process. We do not know who is listening to the events and what they do with them in the listener functions. What if the processing takes longer or 100 classes are listening to the event? We would have to wait at the point of triggering the synchronous triggerEvent call until the event is completely processed before the code continues. But at some places, it is important that the rest of the code works with the current state of the game state because, for example, information could be queried somewhere that is changed by the event listener. Therefore, there are these two options to process events either asynchronously or synchronously.

#pragma once

#include "CoreMinimal.h"
#include "EventSystem/Public/Identifier.h"
#include "EventSystem/Public/IEvent.h"

// Definiere eine Delegate mit einem EventPtr-Parameter.
DECLARE_DELEGATE_OneParam(EventCallback, const EventPtr&);

/**
 * \class EventManager
 * \brief Der zentrale Event-Manager als Singleton implementiert.
 *
 * Diese Klasse verwaltet das Hinzufügen und Entfernen von Event-Listeners,
 * das Warteschlangen von Events und das unmittelbare Auslösen von Events.
 */
class EVENTSYSTEM_API EventManager
{
public:
    /// returns singleton instance
    static EventManager& getInstance() { return oneAndOnly; }

    void Init();    ///< Initialisiert den Event-Manager.
    void Release(); ///< Gibt alle Ressourcen frei und bereinigt.

    /**
     * Fügt einen Listener für einen spezifischen Event-Typ hinzu.
     *
     * \param callback Der Callback, der aufgerufen wird, wenn das Event ausgelöst wird.
     * \param eventHash Der Hashwert des Event-Typs, auf den gehört werden soll.
     * \param listenerName Ein einzigartiger Identifier für den neuen Listener.
     * \throw std::exception bei Hash-Kollisionen oder wenn ein Listener mit demselben Namen bereits existiert.
     */
    void addListener(const EventCallback& callback, const uint32 eventHash, const Identifier& listenerName);

    /**
     * Entfernt den angegebenen Listener.
     *
     * \param listenerName Der Identifier des zu entfernenden Listeners.
     * \throw std::exception, wenn der Listener nicht existiert.
     */
    void removeListener(const Identifier& listenerName);

    /**
     * Reit ein Event zur späteren Auslösung in die Warteschlange ein.
     *
     * \param pEvent Das zu warteschlangende Event.
     * \param pSender Der Sender dieses Events - nullptr ist nicht erlaubt!
     * \note Die tatsächliche Auslösung erfolgt durch 'triggerQueuedEvents'.
     */
    void queueEvent(const EventPtr& pEvent);

    /**
     * Löst ein Event sofort aus - ruft alle Callbacks direkt auf.
     *
     * \param pEvent Das auszulösende Event.
     * \param pSender Der Sender dieses Events - nullptr ist nicht erlaubt!
     */
    void triggerEvent(const EventPtr& pEvent);

    /**
     * Löst alle in der Warteschlange stehenden Events aus.
     *
     * \note Sollte nach 'queueEvent' aufgerufen werden, um die Events zu verarbeiten.
     */
    void triggerQueuedEvents();

private:
    EventManager();
    ~EventManager();

    /// singleton instance - initialized at programm startup; appropriate in this case!
    static EventManager oneAndOnly;

    TArray<EventPtr> queuedEvents; ///< Liste der in der Warteschlange stehenden Events.

    /**
     * Verzeichnis für Event-Callbacks.
     * Event-Identifier -> (Callback-Identifier -> Callback)
     */
    TMap<HashValue, TMap<Identifier, EventCallback>> eventRegistry;

    FCriticalSection m_eventManagerCritialSection;
};

To trigger the event is not much required: Just create a PlayerEvent, wrap it in a Shared Pointer, and trigger an event:

EventPtr evtPtr(new PlayerHealthUpdatedEvent(100, 50));
EventManager::getInstance().queueEvent(evtPtr);

If you want to process the event immediately and not wait for the events to be processed asynchronously, simply call the triggerEvent function instead, while the rest remains the same:

EventPtr evtPtr(new PlayerHealthUpdatedEvent(100, 50));
EventManager::getInstance().triggerEvent(evtPtr);

The Event Listeners

You might have noticed the two functions addListener and removeListener. With these functions, one can simply register a listener for a specific event with the Event Manager:

EventManager::getInstance().addListener(callbackFunction, PlayerHealthUpdatedEvent::Identity, Identifier(GUID.ToString()));

For this, you must pass 3 parameters:

  1. The function that should be called when the event occurs
  2. The identity of the event for which the event function should be called
  3. A unique identifier for the class that registers the event listener.

To demonstrate this, I quickly whipped up a small widget. The only task of the widget is to display the current Player Health on the screen. For this, I have a UTextBlock TextBlock_1, whose value should be updated each time the Player Health changes.

For this to work, I need to define a function that is called when a PlayerHealthUpdatedEvent is triggered. In this case, that's the OnPlayerHealthUpdated function. This function takes, like every callback function for Player Events, only a generic EventPtr as a parameter.

It's also worth mentioning that I have an FGuid GUID for the class that is unique for each object. This is important because the Unreal Engine manages the lifetime of the object, and the UPlayerHealthWidget can exist multiple times at given times, which can lead to conflicts if the listener is unregistered in the destructor. Additionally, I have an EventCallback callbackFunction. This was defined earlier in the Event Manager, and each listener needs such an EventCallback object:

#pragma once

#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include <EventSystem/Public/IEvent.h>
#include <EventSystem/Public/EventManager.h>
#include "PlayerHealthWidget.generated.h"

UCLASS()
class THEATTIC_API UPlayerHealthWidget : public UUserWidget
{
    GENERATED_BODY()
public:
    UPlayerHealthWidget(const FObjectInitializer& ObjectInitializer);

    void OnPlayerHealthUpdated(const EventPtr& evt);

protected:
    void NativePreConstruct() override;
    void NativeConstruct() override;
    void NativeDestruct() override;

private:
    void SetText(const FString& HealthText);

    UPROPERTY(meta = (AllowPrivateAccess = "true", BindWidget))
    class UTextBlock* TextBlock_1;
    
    struct FGuid GUID;
    EventCallback callbackFunction;
    class UTheAtticGameInstance* CurrentGameInstance;
};

The EventCallback object is defined in the constructor and bound with the OnPlayerHealthUpdated function so that it can be passed to the EventManager when registering a listener. Additionally, the GUID is defined in the constructor. This gives me a unique Global Unique ID for each object.

#include "InteractionHintsWidget.h"
#include "GameFramework/InputSettings.h"
#include "Components/TextBlock.h"
#include "Components/Image.h"
#include <EventSystem/Public/EventManager.h>
#include <TheAttic/Events/InteractionHintChangedEvent.h>
#include <TheAttic/TheAtticGameInstance.h>
#include <TheAttic/TheAtticHUD.h>
#include <TheAttic/TheAtticPlayerController.h>

UPlayerHealthWidget::UPlayerHealthWidget(const FObjectInitializer& ObjectInitializer) :
    UUserWidget(ObjectInitializer),
    TextBlock_1(nullptr),
    GUID(),
    callbackFunction(),
    CurrentGameInstance(nullptr)
{
    bIsFocusable = false;

    FPlatformMisc::CreateGuid(GUID);
    callbackFunction.BindWeakLambda(this, [this](const EventPtr& evt) { this->OnPlayerHealthUpdated(evt); });
}

void UPlayerHealthWidget::NativePreConstruct()
{
    return Super::NativePreConstruct();
}

void UPlayerHealthWidget::NativeConstruct()
{
    UGameInstance* gameInstance = GetGameInstance();
    CurrentGameInstance = Cast<UTheAtticGameInstance>(gameInstance);

    CurrentGameInstance->GetEventManager().addListener(callbackFunction, InteractionHintChangedEvent::Identity, Identifier(GUID.ToString()));

    return Super::NativeConstruct();
}

void UPlayerHealthWidget::NativeDestruct()
{
    CurrentGameInstance->GetEventManager().removeListener(Identifier(GUID.ToString()));

    return Super::NativeDestruct();
}

void UPlayerHealthWidget::SetText(const FString& HealthText)
{
    if (TextBlock_1 != nullptr)
    {
        if (!HealthText.IsEmpty())
        {
            TextBlock_1->SetVisibility(ESlateVisibility::Visible);
            TextBlock_1->SetText(FText::FromString(HealthText));
        }
        else
        {
            TextBlock_1->SetVisibility(ESlateVisibility::Hidden);
        }
    }
}

void UPlayerHealthWidget::OnPlayerHealthUpdated(const EventPtr& evt)
{
    PlayerHealthUpdatedEvent* playerHealthUpdatedEvent = (PlayerHealthUpdatedEvent*)(evt.Get());
    if (playerHealthUpdatedEvent != nullptr)
    {
        SetText(FString::FromInt(playerHealthUpdatedEvent->getNewValue()));
    }
}

In NativeConstruct, the listener is then registered with the Event Manager. There I pass the EventCallback and the GUID, which I defined in the constructor. When the NativeDestruct is called, the object and all Event Listener Callbacks associated with the GUID are removed from the Event Manager. After the listener is set up and an event is triggered somewhere, the OnPlayerHealthUpdated function is called. There you can then respond accordingly to the event and update the player's health display as shown in the example.

As you can see, the UPlayerHealthWidget stands completely on its own and has no knowledge of the rest of the code. It is completely detached from the rest of the logic. For example, I don't need to hold a reference to a Player Character or a Player State object and don't need to regularly poll for changes but am anonymously informed when the player's HP has changed. I don't know who informs me about it and don't need to know.

On the other hand, I may have a Player State object whose PlayerHealth is changed, triggering a PlayerHealthUpdatedEvent, and whoever listens to it and what happens in response to that, the Player State object does not and does not need to know.

This decouples the individual systems from each other.

Triggering Queued Events

However, one small thing is still missing. Who calls the function triggerQueuedEvents? Basically, you can handle it as you wish. I've decided to execute it within an implementation of UGameInstance.

#pragma once

#include "CoreMinimal.h"
#include "Engine/GameInstance.h"
#include "TheAtticGameInstance.generated.h"

UCLASS()
class THEATTIC_API UTheAtticGameInstance : public UGameInstance
{
    GENERATED_BODY()

public:
    UTheAtticGameInstance();

    void Init() override;
    void Shutdown() override;

    void StartGameInstance() override;

    void Tick(float DeltaSeconds);

    class EventManager& GetEventManager() { return EventManager::getInstance(); }
};

Here's the corresponding .cpp source:

#include "TheAtticGameInstance.h"
#include "EventSystem/Public/EventManager.h"

UTheAtticGameInstance::UTheAtticGameInstance()
{
}

void UTheAtticGameInstance::Init()
{
    EventManager::getInstance().Init();
    
    Super::Init();
}

void UTheAtticGameInstance::Shutdown()
{
    EventManager::getInstance().Release();
    
    Super::Shutdown();
}

void UTheAtticGameInstance::StartGameInstance()
{
    Super::StartGameInstance();
}

void UTheAtticGameInstance::Tick(float DeltaSeconds)
{
    EventManager::getInstance().triggerQueuedEvents();
}

The UGameInstance is a unique class that is instantiated once at the beginning of the game and remains even when switching levels. You just need to change the Game Instance in the Project Settings under Maps & Modes so that your own Game Instance class is used.

Setting Game Instance in Project Settings

When calling triggerQueuedEvents, the events that were queued through the call of queueEvent are triggered.

Final Words

I hope I was able to explain everything clearly and vividly enough. If not, I have prepared a small example project that demonstrates the use of the Event Manager in a minimal example. You can download and explore this project from the following link:

Download the Example Project Source Code

Profile picture

Artur Schütz

Senior Software Developer specializing in C++ graphics and game programming. With a background as a Full Stack Developer, Artur has now shifted his focus entirely to game development. In addition to his primary work, Artur explores the field of Machine Learning, experimenting with ways to enrich gameplay through its integration.