How-To: Event Systems in Unity

How to get your components to talk to each other.

ยท

11 min read

At some point, you've likely encountered a situation where you needed to trigger functions or change variables in response to a change elsewhere in your code. For example, you may want to:

  • Play sad music and display a game-over screen when the player's health reaches 0.

  • Change the animation set when the player's health drops below 25%.

  • Trigger a special ability when the player is damaged.

While you can have these components check the player's health every Update(), this quickly becomes unwieldly.

Running checks in Update() is wasteful; it still consumes processor time even if there is no change. When you have many scripts on many objects checking many different variables, this can significantly impact your performance. The real cost, however, lies in code maintenance. You need to maintain references to everything and the code to check each condition for each object throughout your codebase, making it challenging to modify or expand your code.

Instead of relying on thousands of if/else statements, consider building an Event System.

What is an Event System?

An event system is a system that allows scripts to broadcast information to other scripts that are listening. Think of it as a news radio; recent events are broadcast on the news channel, and anyone tuned in and listening will receive this information without repeatedly checking or asking for updates.

Event systems are essential for games with any degree of complexity. They enable different scripts to communicate with each other in a clean and maintainable way, without requiring an extensive web of references or allowing scripts to be directly controlled by other unrelated scripts.

While there are many ways to structure an event system depending on your needs, two methods I recommend are via an Event Manager or Event Channels.

Method 1 : Event Manager Monobehavior

A popular method is to create a singleton event manager, an object of an EventManager class designed so that only one instance exists at a time and is accessed through the class's properties.

using System;
using System.Collections.Generic;
using UnityEngine;

/// <summary> Broadcasts events and associated data to interested parties. </summary>
public class EventManager : MonoBehaviour
{
    private Dictionary<GameEvent, Action<Dictionary<string, object>>> eventDictionary;

    private static EventManager eventManager;

    public static EventManager instance
    {
        get
        {
            // if no instance is set, search for one in the scene
            if (!eventManager)
            {
                eventManager = FindFirstObjectByType(typeof(EventManager)) as EventManager;

                if (!eventManager)
                {
                    // Still didn't find one, throw an error.
                    Debug.LogError("There needs to be one active EventManager script on a GameObject in your scene.");
                }
                else
                {
                    // initialize the event dictionary for the newly found instance And flag it
                    // so it is not destroyed on scene loading
                    eventManager.Init();
                    DontDestroyOnLoad(eventManager);
                }
            }
            return eventManager;
        }
    }

    /// <summary> Initializes the event dictionary. </summary>
    private void Init()
    {
        if (eventDictionary == null)
        {
            eventDictionary = new Dictionary<GameEvent, Action<Dictionary<string, object>>>();
        }
    }

    /// <summary> Registers a function to the event listener. </summary>
    /// <param name="eventID">  The event we are listening for. </param>
    /// <param name="listener"> The function that is subscribing. </param>
    public static void StartListening(GameEvent eventID, Action<Dictionary<string, object>> listener)
    {
        if (eventManager == null) return;

        Action<Dictionary<string, object>> thisEvent;

        if (instance.eventDictionary.TryGetValue(eventID, out thisEvent))
        {
            thisEvent += listener;
            instance.eventDictionary[eventID] = thisEvent;
        }
        else
        {
            thisEvent += listener;
            instance.eventDictionary.Add(eventID, thisEvent);
        }
    }

    /// <summary> Unregisters a function from the event listener. </summary>
    /// <param name="eventID">  The event we were looking for. </param>
    /// <param name="listener"> The function that was listening. </param>
    public static void StopListening(GameEvent eventID, Action<Dictionary<string, object>> listener)
    {
        if (eventManager == null) return;
        if (instance.eventDictionary.TryGetValue(eventID, out Action<Dictionary<string, object>> thisEvent))
        {
            thisEvent -= listener;
            instance.eventDictionary[eventID] = thisEvent;
        }
    }

    /// <summary> Triggers an event, activating all the functions registered to the event. </summary>
    /// <param name="eventID">   The event to trigger. </param>
    /// <param name="eventData"> The data carried by the event. </param>
    public static void TriggerEvent(GameEvent eventID, Dictionary<string, object> eventData)
    {
        if (instance.eventDictionary.TryGetValue(eventID, out Action<Dictionary<string, object>> thisEvent))
        {
            thisEvent?.Invoke(eventData);
        }
    }
}

This general-purpose EventManager class has three important functions, StartListening(), StopListening(), and TriggerEvent(). Because they are static, they can be accessed from anywhere, without having to find or store a reference to the manager (it does this itself).

The list of subscribers is stored in a dictionary of delegates in the form of Actions, with a GameEvent enum as the key. Using an enum as the key is beneficial due to the ability to use your IDE's IntelliSense to find or autocomplete it, preventing bugs caused by typos.

Here's an example GameEvent:

public enum GameEvent
{
    NetworkManagerLoaded,
    FadeOutCompleted,
    FadeInCompleted,
    PlayerTakeDamage,
    PlayerDealDamage,
    PlayerDodged,
    PlayerControlActivated,
    PlayerControlDeactivated,
    GamePaused,
    GameUnpaused,
    SettingsChanged,
    UpdateHUD
}

To subscribe a function to an event, you pass the function and the event you want it to listen for to the manager through StartListening().

// Start listening for game pause events
EventManager.StartListening(GameEvent.GamePaused, OnGamePaused);

This registers the function to the delegate associated with that game event.

To trigger an event, you call TriggerEvent(), creating a new event data dictionary containing the data you need to broadcast in the form of a Dictionary<string, object>.

EventManager.TriggerEvent(GameEvent.PlayerTakeDamage, new Dictionary<string, object> { 
    { "Player", playerTwo },
    { "Amount", damageTaken },
    { "Source", damageSource }
 });

The event data dictionaries have string keys and accept any object, allowing you to broadcast any type of data or multiple types through the same function using human-readable labels.

To use the data from the dictionary in the subscriber function, retrieve it from the dictionary by key and cast it to the appropriate type. The data must be cast because it is stored as a generic object.

public void OnPlayerControlDeactivated(Dictionary<string, object> data)
        {
            PlayerEntity affectedPlayer = (PlayerEntity)data["Player"];
            // Continue doing stuff
        }

Finally, once you're done listening for events, unsubscribe from the event manager using the StopListening() method. This is necessary because the delegate does not check for duplicate functions, nor does it automatically remove references to inactive or destroyed objects. This creates the potential for memory leaks and bugs such as functions being called multiple times per event trigger.

There is no harm in unsubscribing an unsubscribed function, so a good practice is to unsubscribe from every event you listen for during OnDisable() or OnDestroy().

private void OnDisable() 
{
    // Stop listening for game events
    EventManager.StopListening(GameEvent.GamePaused, OnGamePaused);
    EventManager.StopListening(GameEvent.GameUnpaused, OnGameUnpaused);
    EventManager.StopListening(GameEvent.PlayerDead, OnPlayerDead);
}

That's it! Now you have an event manager! ๐Ÿ‘๐Ÿฝ๐ŸŽ‰

Just add the EventManager component to a game object in the main scene.

Summary

Pros:

  • Accessible. Universally accessible without references or searching for instances.

  • Flexible. Can transmit any number and any type of parameters to the subscribers.

  • Simple. All events are handled the same way through the same three functions regardless of what they broadcast.

Cons:

  • Inefficient. Requires the creation of a new Dictionary<string, object> each trigger, regardless of if any data is actually being transmitted. Using transmitted data requires casting, an additional performance cost.

  • Error-Prone. String keys are prone to typos that will not be picked up by the IDE; "ItemUsed", "Item Used", and "Item used" are three completely different keys.

  • Increased Debugging Difficulty. It is not possible for an IDE to determine which functions are listening to what events, making it more difficult to track down bugs.

  • Non-Serializable: The delegates cannot be serialized, meaning the listeners cannot be saved to a file and must be re-registered in code if the game is reloaded.

Notes:

  • You could remove the event data dictionaries to avoid the memory allocation and performance issues if you do not need to broadcast data.

  • You may choose to use an EventData enum for a key, or replace the dictionary with an EventData struct to avoid issues with string keys or casting.

Method 2 : Event Channel Scriptable Objects

An alternative to the monolithic event manager class is to separate your events into individual objects called event channels. This is achieved using Scriptable Objects. An event channel that passes no arguments looks like the following.

/// <summary>
/// An event channel that does not broadcast a variable.
/// </summary>
[CreateAssetMenu(fileName = "New Void Event Channel", menuName = "Scriptable Objects/Events/Void Event Channel")]
public class EventChannel : ScriptableObject
{
    // -----------------------------------------
    public UnityAction Event;
    public bool autoClean = true;
    // -----------------------------------------

    /// Clears the event list when out of scope
    private void OnDisable()
    {
        if (autoClean) 
        {
            Event = null;
        }
    }

    /// Triggers the event in this channel.
    public void Broadcast()
    {
        Event?.Invoke();
    }
}

From this base class, you create new instances of the scriptable object representing the events you need, such as GamePaused, GameUnpaused, MenuOpened, or StartSceneTransition.

๐Ÿ’ก
Tip: If you do not know how to use or instantiate scriptable objects, you should check out the official documentation on the subject.

To subscribe to the event, the subscriber must first obtain a reference to the event channel object in question. This is easily done by dropping a reference to the channel in the listener script using the Unity inspector.

In code, subscribe to the event's delegate using the additive compound assignment operation, as you would for any other delegate.

// This is set via inspector to the PlayerDamaged event channel object.
[SerializeField] private EventChannel playerDamaged;

private void Start() 
{
    // Start listening to the event
    playerDamaged.Event += OnPlayerDamaged;
}

private void OnDisable() 
{
    // stop listening to the event
    playerDamaged.Event -= OnPlayerDamaged;
}

// This function is run when the event is triggered
public void OnPlayerDamaged() 
{
    Debug.Log("Player took damage.");
}

Once referenced, trigger the event either directly or using the event's Broadcast() function.

// Trigger directly
playerDamaged.Event?.Invoke();

// The helper function just looks cleaner
playerDamaged.Broadcast();

As with the Event Manager monobehavior, it is important to remove all event subscriptions from the event channel when no longer needed. Scriptable objects persist through sessions and will collect references to destroyed or inaccessible objects as gameplay continues if not properly handled. The event channel's OnDisable() function clears the delegate when the channel itself is unloaded to prevent this persistence, but other objects can still create garbage and null reference exceptions if they do not unregister themselves properly.

However, you can disable the auto-clearing if you want persistent event references (such as setting up level-specific events or UI events). In this case, properly unsubscribing listeners is crucial.

Event Channels With Data

You can create additional event channels that accept one or more parameters for more complex events. An easy way to implement this is with generic classes.

/// <summary>
/// An event channel that broadcasts a single variable.
/// </summary>
[CreateAssetMenu(fileName = "New Single Event Channel", menuName = "Scriptable Objects/Events/Single Event Channel")]
public class SingleEventChannel<T> : ScriptableObject
{
    // -----------------------------------------
    public UnityAction<T> Event;
    public bool autoClean = true;
    // -----------------------------------------

    /// Clears the event list when out of scope
    private void OnDisable()
    {
        if (autoClean) 
        {
            Event = null;
        }
    }

    /// Triggers the event in this channel.
    public void Broadcast(T firstParameter)
    {
        Event?.Invoke(firstParameter);
    }
}

While you can't use a generic class directly in Unity, you can inherit from it to create concrete variations that all share the same code. You can create new data channels by writing empty channel classes that derive from the generic one but with a specified type.

/// <summary>
/// An event channel that broadcasts a single integer.
/// </summary>
[CreateAssetMenu(fileName = "New Int Event Channel", menuName = "Scriptable Objects/Events/Int Event Channel")]
public class IntEventChannel : SingleEventChannel<int>
{
}

/// <summary>
/// An event channel that broadcasts a single player reference.
/// </summary>
[CreateAssetMenu(fileName = "Player Event Channel", menuName = "Scriptable Objects/Events/Player Event Channel")]
public class PlayerEventChannel : SingleEventChannel<Player>
{
}

These concrete implementations can be instantiated as ScoreChanged, DamageTaken, and TokensRecieved, or PlayerDied, PlayerSpawned, and PlayerPickedUpToken, for example.

You can expand this further to create even more complex channels.

/// <summary>
/// An event channel that broadcasts four variables.
/// </summary>
[CreateAssetMenu(fileName = "New Quad Event Channel", menuName = "Scriptable Objects/Events/Quad Event Channel")]
public class QuadEventChannel<T, I, J, K> : ScriptableObject
{
    // -----------------------------------------
    public UnityAction<T, I, J, K> Event;
    public bool autoClean = true;
    // -----------------------------------------

    /// Clears the event list when out of scope
    private void OnDisable()
    {
        if (autoClean) 
        {
            Event = null;
        }
    }

    /// Triggers the event in this channel.
    public void Broadcast(T first, I second, J third, K fourth)
    {
        Event?.Invoke(first, second, third, fourth);
    }
}

[CreateAssetMenu(fileName = "New PTIV Event Channel", menuName = "Scriptable Objects/Events/PTIV Event Channel")]
public class PTInvV3EventChannel : QuadEventChannel<Player, Token, Inventory, Vector3> 
{
}

This could be used to implement events like PlayerDepositsTokenIntoInventoryFromDirection, which is might be overly specific but serves as a good demonstration.

And there you have it. Event channels! ๐ŸŽ™๏ธ๐Ÿ“ก

Summary

Pros:

  • Compartmentalized: Each channel exists as its own globally accessible file with no need for a manager game object.

  • Performant. Uses only delegates and function parameters, with no casting or object creation required.

  • Serializable: References and listener lists can be saved to a file, allowing them to persist across scene loads and be set via the inspector.

  • Easier Development. Strong typing allows for autocompletion and code-traversal by the IDE.

Cons:

  • Game Asset: Each game event is its own asset in the filesystem, which can be difficult to reference without the Unity inspector and can be slow to find in folders when there is a large number of events used.

  • Requires References: Scripts need to reference a specific channel instance, which must be updated or restored if lost by error or code modification.

  • Persistent: Failing to unsubscribe properly results in additional headaches due to the persistence of the scriptable objects, especially during development.

  • More Setup Time: Unlike the Event Manager, you need to create and manage event channels individually, as well as write a new event channel class for every combination of parameters you want to broadcast.

Notes:

  • Complex events may benefit from using an EventArgs struct to wrap the data, so adding data to the broadcast only requires you to add a property to the struct instead of a new parameter to the event channel and every listener it calls.

  • You may wish to add a name and description string field to the event channel so you can make notes of what the event is intended to do or how it is intended to be used.

Conclusion

Your event systems are a crucial part of your game's architecture; they allow for clean and maintainable communication between hundreds or even thousands of different components. But there are many ways to do it, and building the right tool for your use case makes your life as a developer easier and increases the chance you will successfully publish your game!

Did you find this article valuable?

Support Bear Evans by becoming a sponsor. Any amount is appreciated!

ย