Using an Enum-Based Event Bus Pattern In Unity

Written by dmitrii | Published 2023/04/18
Tech Story Tags: unity | c-sharp | game-development | video-games | software-engineering | software-architecture | enum-based-event-bus-pattern | unity-development

TLDRThe Unity event bus is a central bus through which components can send and receive messages. The bus acts as a mediator between the components, and they can exchange messages without the need for explicit coupling with each other. The event bus can be shared across the entire code, but there may also be separate buses that differ in purpose.via the TL;DR App

The essence of the pattern is that it creates a centralized bus through which components can send and receive messages (events). The bus acts as a mediator between the components, and they can exchange messages without the need for explicit coupling with each other.

Hello everyone, my name is Dmitrii Ivashchenko, and I’m a Software Engineer at MY.GAMES. In this article, we’ll talk about an enum-based event bus in Unity, which we’ll get to in just a moment.

Back to the event bus pattern itself, there are several ways to implement this, but the general idea remains the same: components send messages (events) to the bus, and other components subscribe to these events and receive them.

There are two main methods to implement an event system: Subscribe and Raise:

// At the point where the event occurs, we call Raise
// with the event type and data
EventBus.Raise(some_event_type, arguments);

// In the handler, we subscribe to a specific type
// of events and call the OnSomeEventRaised method
EventBus.Subscribe(some_event_type, OnSomeEventRaised);

Often the event type is set as a string, but there are also implementations where a class for the passed data is used as the event type:

public struct BuyEvent 
{
    public int ItedId;
}

// Raising a new event
EventBus.Raise<BuyEvent>(new BuyEvent{ ItemId = 1 });

// Subscription to event
EventBus.Subscribe<BuyEvent>(OnPlayerBuy);

There are also more interesting implementations where interfaces are used for subscription. The event bus can be shared across the entire code, but there may also be separate buses that differ in purpose and method of message delivery.

Another interesting way to implement EventBus is Enum-based.

Why enums?

Using enums as event types has several advantages:

  • Improved code readability
  • Low overhead for Enum processing
  • Code autocompletion in IDE, with ease of adding new types
  • Eliminated possibility of incorrect spelling, which is the case when using strings
  • It adds the possibility of adding extension methods to extend functionality

To more easily illustrate the main principle, some details have been omitted in the code examples below. We’ll also be using the ReactiveCommand class from the UniRx library to transmit events. Instead of UniRx, any other means of storing message handlers could also be used, such as C# delegates and events.

The complete source code is available on GitHub.

Using an Enum as a Event Bus

One way to view an Enum is as a type of data bus, with its elements representing concrete types of events delivered by this bus:

// Event bus for user interface-generated events
public enum UiEvent
{
    // Event on clicking on the "buy" button 
    BuyButtonClicked,

		// Event on opening an in-game window
		ScreenOpened,

		// Event on completing the tutorial
		TutorialFinished,

    ...
}

Using C# extension methods, we can add methods for sending and subscribing to events to this enum:

public static class UiEventExtensions
{
    // Declare a static readonly ReactiveCommand of type UiEvent
    private static readonly ReactiveCommand<UiEvent> 
        _uiEventCommand = new ReactiveCommand<UiEvent>();
    
    // Extension method to raise a UiEvent
    public static void Raise(this UiEvent evnt)
    {
        // Execute the ReactiveCommand with the UiEvent
        _uiEventCommand.Execute(evnt);
    }
    
    // Extension method to subscribe to a UiEvent
    public static IDisposable Subscribe(this UiEvent evntType, Action action)
    {
        // Return a subscription to the ReactiveCommand, 
        // filtered by the UiEvent type
        return _uiEventCommand.Where(evnt => evnt == evntType)
                              .Subscribe(evnt => action());
    }
}

Now, we can send and receive events in the game logic like so:

// Raise the BuyButtonClicked event
**UiEvent.BuyButtonClicked.Raise();**

// Subscribe to the BuyButtonClicked event
var disposable = **UiEvent.BuyButtonClicked.Subscribe**(OnBuyButtonClicked);

// ...

// Unsubscribe from the event
disposable.Dispose();

To pass parameters together with an event, we’ll need an additional class that will store the event type and data.

Example implementation:

// This class is used to store information about a UI event
public class UiEventParam
{
    // The type of the UI event
    public UiEvent Type { get; }

    // Constructor that sets the type of the UI event
    protected UiEventParam(UiEvent type) => Type = type;

    // Get the data associated with the UI event
    public T Get<T>() => ((UiEventParam<T>) this).Data;
}

// This class is used to store data associated with a UI event
public class UiEventParam<T> : UiEventParam
{
    // The data associated with the UI event
    public readonly T Data;

    // Constructor that sets the type and data of the UI event
    public UiEventParam(UiEvent type, T data) : base(type) => Data = data;
}

public static class UiEventExtensions
{
    // Declare a static readonly ReactiveCommand of type UiEventParam
    private static readonly ReactiveCommand<UiEventParam> _uiEventParam = new ReactiveCommand<UiEventParam>();
    
    // Create a method to raise an event with a generic type
    public static void Raise<T>(this UiEvent evnt, T data) 
        // Execute the ReactiveCommand with a new UiEventParam object containing the event and data
        => _uiEventParam.Execute(new UiEventParam<T>(evnt, data));
    
    // Create a method to subscribe to an event with a generic type
    public static IDisposable Subscribe<T>(this UiEvent evntType, Action<T> action)
    {
        // Return a subscription to the ReactiveCommand, filtering for the specified event type
        return _uiEventParam
            .Where(evnt => evnt.Type == evntType)
            .Subscribe(evnt =>
                // Execute the action with the data from the UiEventParam object
                action(evnt.Get<T>()));
    }

    public static IObservable<T> Where<T>(this UiEvent evntType, Func<T, bool> predicate)
    {
        // Create an observable sequence from the UiEventParam
        var observable = _uiEventParam.Where(evnt =>
            // Check if the event type matches the given event type 
            // and the predicate returns true
            evnt.Type == evntType && predicate(evnt.Get<T>()));
        
        return observable.Select(evnt => evnt.Get<T>());
    }
}

In the example above, we created the UiEventParam class for generalized access to the Data field of the UiEventParam<T> class, and we also added the Where method that allows filtering events based on the passed data. With these changes, we can further expand the possibilities of using the event bus:

void ExampleOfRaising()
{
    // Raise the SetSoundVolume event with a value
    **UiEvent.SetSoundVolume.Raise(0.5f);**
}

void SubscribeOnEvents()
{
    _disposable = new CompositeDisposable
    {
        // Subscribe to the SetSoundVolume event with any value
        **UiEvent.SetSoundVolume.Subscribe<float>(OnSetVolume),**
        
        // Subscribe to the SetSoundVolume event
        // and call the OnMute method when it is raised with a value of 0f
        **UiEvent.SetSoundVolume.Where<float>(x => x == 0f).Subscribe(OnMute),**
    };
}

Encapsulating network events in an enum-based bus

Similarly, we can wrap any network interactions in an enum-extension, whether that’s message exchange with the backend, or anything else. Let's consider the example of the network library Photon Unity Networking 2. We'll write a C# extension that wraps the receiving and sending of events in an enum called NetEvent:

public static class NetEventExtensions
{
    // Create a new ReactiveCommand of type NetEventParam
    private static readonly ReactiveCommand<NetEventParam> 
        _netEventParam = new ReactiveCommand<NetEventParam>();

    // Extension method to raise a NetEvent with data
    public static void Raise<T>(this NetEvent evnt, T data)
    {
        // Raise the event with the data, options, and reliable send
        PhotonNetwork.RaiseEvent((byte) evnt, data, GetOptions(evnt), SendOptions.SendReliable);
    }

    // Extension method to subscribe to a NetEvent
    public static IDisposable Subscribe<T>(this NetEvent evntType, Action<T> action)
    {
        // Return a subscription to the ReactiveCommand, 
        // filtered by the event type, 
        // and call the action with the data
        return _netEventParam.Where(evnt => evnt.Type == evntType)
                             .Subscribe(evnt => action(evnt.Get<T>()));
    }
    
    // Method to be called when a PhotonEvent is received
    public static void OnPhotonEventReceived(EventData data)
    {
        // Cast the data code to a NetEvent
        var evnt = (NetEvent) data.Code;
        // Execute the ReactiveCommand with a new NetEventParam containing the event and custom data
        _netEventParam.Execute(new NetEventParam<object>(evnt, data.CustomData));
    }
}

The method OnPhotonEventReceived should be called in your class implementing the Photon interface IOnEventCallback.OnEvent. We can now work with network events, just like we did before, with UI events:

// Enum for network events
public enum NetEvent
{
    // Event for player state
    PlayerReady,

    // Event for client ping
    ClientPing,

    // Event for battle finish
    BattleFinish,

    // ...
}

// Method to raise the PlayerReady event
public void OnGameLoaded()
{
    // Raise the PlayerReady event with the player's ID
    **NetEvent.PlayerReady.Raise(_playerId);**
}

// Method to subscribe to the PlayerReady event
public void SomeGameManagerOnAnotherDevice()
{
    _disposable = new CompositeDisposable
    {
        // Subscribe to the PlayerReady event
        **NetEvent.PlayerReady.Subscribe<int>(OnPlayerReady)**
    };
}

Other types of buses

A bus can be characterized not only by the method of delivery, but also by the environment for message propagation. The above examples show buses that deliver events at runtime, but we can also identify a separate bus for editor events (in Edit and Play-mode):

[InitializeOnLoad]
public static class EditorBridge
{
    // Static constructor that is called when the class is loaded
    static EditorBridge()
    {
        var disposable = new CompositeDisposable
        {
            // Subscribe to the event and log a message when it is raised
            **EditorEvent.SomeEventName.Subscribe**(() => Debug.Log("Editor event"))
        };

        // Add a handler to the EditorApplication.quitting event
        // that disposes of the CompositeDisposable
        EditorApplication.quitting += () => disposable.Dispose();
    }

    // Create a menu item in the Tools menu
    [MenuItem("Tools/Raise Test Event")]
    public static void RaiseTestEvent()
    {
        **EditorEvent.SomeEventName.Raise();**
    }
}

In this case, the implementation of the Subscribe and Raise methods will not differ from UiEventExtensions. In Edit-Mode, the event bus can be used to exchange messages between different custom editors, allowing for interface updates and synchronizing data between them.

Another useful type of bus is a deferred execution bus. In the previous examples, the sent event is executed synchronously in the same frame. However, there are tasks that require time for processing but do not demand immediate execution. For such events, a separate manager is required which will check whether there is enough time in the current frame to execute the handlers, or whether to wait for the next frame:

public class DelayedEventsProcessor
{
    // This method is called when the game starts
    [RuntimeInitializeOnLoadMethod]
    public static async void Init()
    {
        // Create a cancellation token source
        var source = new CancellationTokenSource();
        // Store the current delta time
        var time = Time.deltaTime;

        // While the cancellation is not requested,
        // the queue is not empty
        // and the execution limit is not exceeded
        while (!source.IsCancellationRequested 
               && !DelayedEventsQueue.IsEmpty()
               && (Time.deltaTime - time) > ExecutionLimit)
        {
            // Process the events in the queue
            DelayedEventsQueue.ProcessEvents();
            // Waiting for next frame
            await Task.Yield();
            // Update the delta time
            time = Time.deltaTime;
        }
        
        // When the application is quitting, cancel the token
        Application.quitting += () => source.Cancel();
    }
}

One can separately highlight the DebugEvent bus, and its events will only be handled in development builds and will be turned off in release configurations.

Pros and cons of event buses

Using the event bus pattern improves Unity game architecture, simplifies development and maintenance, and enhances flexibility and performance. Let’s look in more detail:

  1. Separation of logic: The Event Bus pattern allows for the separation of game logic into components, which simplifies its understanding and maintenance. Each component can process only the events it needs, reducing coupling and increasing system flexibility.
  2. Flexibility: Using an event bus makes it easy to add and remove components, simplifying game expansion and functionality changes.
  3. Asynchronicity: Events processed through the bus can be called asynchronously, improving game performance and reducing the likelihood of delays in operation.
  4. Extensibility: The event bus pattern can be used to create plugins and modules that can be easily integrated into the game.

However, it should be remembered that the Event Bus can also lead to some negative effects:

  • Worsening of debugging: events that are processed through the bus can be triggered asynchronously, which makes it difficult to debug the application.
  • Loss of flow control: if event processing is not organized correctly, unpredictable interaction between components may occur, leading to errors and failures.
  • Increased code complexity: code that uses the event bus may be harder to understand and maintain, as different components may be subscribed to the same event.
  • Increased message transmission delay: transmitting messages through the bus can increase the delay in application operation, as events must be processed at the other end of the bus.

Conclusion

We’ve learned how to use enums to mark data buses and message types, and have explored how to wrap event buses transmitted in different ways and in different environments with enums. Putting it all together, we can use this approach to uniformly handle any type of event, thereby improving code readability and comprehension.

public void Initialize()
{
    _disposable = new CompositeDisposable
    {
        // Uniform subscription to events 
        // incoming from different buses

        **UiEvent.ExitBattleClick**.Subscribe(OnExitBattleClick),   

        **BackendEvent.BattleReset**.Subscribe(OnBattleReset),

        **DelayedEvent.ChangeWindDirection**.Subscribe<Direction>(OnChangeWindDirection),

        **NetEvent.PlayerReady**.Subscribe<PlayerReadyData>(OnPlayerReady),

        **DebugEvent.KillOpponentUnits**.Subscribe(OnKillOpponentUnits),

        **EditorEvent.SelectScene**.Subscribe<string>(OnEditorSelectScene),
    };
}

To recap once more: the event bus pattern has several advantages, such as reducing the coupling between components, more flexible and extensible code, and the ability to handle messages asynchronously. However, its use can lead to code complexity and increase the difficulty of debugging the application. Therefore, before using the pattern, it is necessary to carefully evaluate its necessity and advantages in a specific project.

Source code is available on GitHub.


Written by dmitrii | Crafting mobile games and robust backend systems for over a decade
Published by HackerNoon on 2023/04/18