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.
Using enums as event types has several advantages:
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.
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),**
};
}
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)**
};
}
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.
Using the event bus pattern improves Unity game architecture, simplifies development and maintenance, and enhances flexibility and performance. Let’s look in more detail:
However, it should be remembered that the Event Bus can also lead to some negative effects:
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.