Event Systems
Event Systems
(This post is split up into 3 sections: an overview, the exploration of the system, and a final comparison of it versus other systems I've built.)
(Source code: https://github.com/surmwill/ProphetAR/tree/master/ProphetAR_Unity/Assets/Scripts/GameEvents/Framework)
Overview
This post will pertain to the event system I designed and am using in my game, which ended up being a tension between performance and flexibility, wrestling with C#’s type system, and balancing the power of reflection with the cost of its inefficiencies.
Below is a high-level overview suitable for any reader, with an in-depth technical exploration following below.
The event system is intended to provide a way for disjoint systems to be able to communicate with one another. For example, the separation between gameplay elements (characters/enemies in the game) and UI. An enemy walking around in the game should not be able to control the popup shown on your screen. But sometimes an enemy needs to be able to communicate with a UI element, perhaps, increasing a counter on death.
For this an event system is needed. The enemy sends an event to the event system, not caring who is listening or what they do in response, and the UI listens for the specific event. In this way, relative to the enemy, they know nothing about the UI system, what a UI system is, or if one is even in the game. They are simply calling out their actions (sending events) to the void as they perform them. This restricts the power of the enemy to only being an enemy, and not a UI controller.
However, just because we can connect any two parts of the game together with the event system does not mean we should force all communication through it. A character’s body should be able to talk to its parts directly, as it makes sense for these parts to naturally and directly know about each other. Therefore the event system is the widest net to fall back to in methods of communication, but a necessary one to have nonetheless.
I use it for relaying turn state (turn begin, turn actions built, turn end), character movement (I moved to this position, is anything going to happen to me now?), and accumulating the actions we need to complete the turn (we don't know or care where the actions come from, but we do need to receive them), among other things.
This is the third event system I’ve created, and you can read about the flaws in the other two in the technical details below. There were three main goals:
I wanted it to be lightweight and purely C#. Something that can be added to any C# class anywhere, and not requiring any advanced functionality of Unity or a MonoBehavior.
I wanted the listener's response to be typed. That is, when receiving data, we receive it in its typed form and not an object that we have to remember to cast to the correct type.
It has to be efficient.
Exploration
Ideally, something like:
public class MyClass : IMovementListener, IAttackListener
{
// Assigned somehow (constructor, singleton)
private GameEventProcessor _eventProcessor;
// IMovementListener implementation
void OnMovement(MovementData data) {}
// IAttackListener implementation
void OnAttack(AttackData data) {}
public void Bind(bool bind)
{
if (bind)
{
_eventProcesser.AddListener<IMovementListener>(this);
_eventProcessor.AddListener<IAttackListener>(this);
}
else
{
_eventProcessor.RemoveListener<IMovementListener>(this);
_eventProcessor.RemoveListener<IAttackListener>(this);
}
}
}
Where a GameEventProcessor is a simple C# class.
GameEventProcessor eventProcessor = new GameEventProcessor();
After meeting reality, the final result is more like:
public class MyClass : IMovementListener, IAttackListener
{
// Assigned somehow (constructor, singleton)
private GameEventProcessor _eventProcessor;
// IMovementListener implementation
void IGameEventWithTypedDataListener<IMovementListener, MovementData>.OnEvent(MovementData data) {}
// IAttackListener implementation
void IGameEventWithTypedDataListener<IAttackListener, AttackData>.OnEvent(AttackData data) {}
public void Bind(bool bind)
{
if (bind)
{
_eventProcesser.AddListenerWithData<IMovementListener, MovementData>(this);
_eventProcessor.AddListenerWithData<IAttackListener, AttackData>(this);
}
else
{
_eventProcessor.RemoveListenerWithData<IMovementListener>(this);
_eventProcessor.RemoveListenerWithData<IAttackListener>(this);
}
}
}
With some extra words and types, it is very similar.
There are three main parts to the system.
The event that is raised
The listener that receives and responds to the event
The system that sits between the two (called here, an event processor)
It should be noted that the complete system is divided into two nearly identical parts: game events that pass data and game events that don’t. For the sake of brevity, only game events that pass data will be explored.
The event that is raised:
public class GameEventWithTypedData<TData>: GameEventWithData
{
public TData Data { get; }
public GameEventWithTypedData(TData data) : base(data)
{
Data = data;
}
}
Each event derives from the above class, enclosing the type of data it will pass along.
GameEventWithData is the untyped parent of all the GameEventWithTypedData. This is when we need to reference a game event that passes data, but we don’t care about the underlying type it’s passing. Or, if we have a list of these, whether they are all aligned with the same type.
public abstract class GameEventWithData : GameEvent
{
public object RawData { get; }
protected GameEventWithData(object rawData)
{
RawData = rawData;
}
}
And GameEvent is the empty base class that GameEventWithoutData also derives from (game events without data are not explored here). This is used when we need a most general reference to a game event, and we don’t care if that event passes data or not.
public abstract class GameEvent
{
// Empty
}
Here is a sample game event raised when a character’s turn is complete, passing along the character as data:
public class GameEventCharacterTurnComplete : GameEventWithTypedData<Character>
{
public GameEventCharacterTurnComplete(Character data) : base(data){}
}
The listener that receives and responds to the event
public interface IGameEventWithTypedDataListener<TInterfaceSelf, in TData> :
IGameEventListener where TInterfaceSelf : IGameEventWithTypedDataListener<TInterfaceSelf, TData>
{
public void OnEvent(TData data);
}
Each listener derives from the above, supplying the type of data it will receive.
IGameEventListener is the base interface of game event listeners that receive data, and listeners that don’t (again, listeners without data are not covered here)
public interface IGameEventListener
{
// Empty
}
Something to note (and can be seen below), is that interface supplies itself as its own type parameter in TInterfaceSelf. To see the problem, if we’re listening to an event that passes an int, we’ll have an OnEvent(int dataOne) receiver method. If we want to listen to another event that also passes an int, we need another OnEvent(int dataTwo) method. But these are methods with identical signatures. The second event will erroneously use the first event’s responding method.
To fix this we supply a generic parameter which further specifies the listener type.
For example:
void IGameEventWithTypedDataListener<ITestGameEventIntOneListener, int>.OnEvent(int dataOne){}
void IGameEventWithTypedDataListener<ITestGameEventIntTwoListener, int>.OnEvent(int dataTwo){}
Both receive a int as event data, but since one is specified as a ITestGameEventIntOneListener and the other ITestGameEventIntTwoListener, the two different game events will go to their two different responding methods.
This problem is even more apparent with game events that don’t pass data. It is clear that responding to multiple of these events will require multiple parameterless receiving methods, all being void OnEvent(). The extra type parameter is needed to distinguish each response.
Lastly, we hook up a listener to the game event it corresponds with with the attribute ListensToGameEventType. Here is a sample listener that listens to when a character’s turn is complete.
[ListensToGameEventType(typeof(GameEventCharacterTurnComplete))]
public interface IGameEventCharacterTurnCompleteListener :
IGameEventWithTypedDataListener<IGameEventCharacterTurnCompleteListener, Character>
{
}
[AttributeUsage(AttributeTargets.Interface)]
public class ListensToGameEventTypeAttribute : Attribute
{
public Type TypeGameEvent { get; }
public ListensToGameEventTypeAttribute(Type typeGameEvent)
{
if (!typeof(GameEvent).IsAssignableFrom(typeGameEvent))
{
throw new ArgumentException($"`{typeGameEvent.Name}` must be a `{nameof(GameEvent)}` type.");
}
TypeGameEvent = typeGameEvent;
}
}
The system that sits between the two
We now have a game event that sends data, a listener that receives that data, and the listener specifies which game event it receives data from. All we need to do now is match event raises with listeners, and this is done with a game event processor.
Let’s look at what adding an event listener looks like.
We have this line for event binding:
_eventProcessor.AddListenerWithData<IGameEventCharacterTurnCompleteListener, Character>(character);
somewhere in this class (below), likely in OnEnable(), which implements the corresponding IGameEventCharacterTurnCompleteListener:
public class TestTurnScreenCharacterAbilitiesToolbarUI : MonoBehaviour,
IGameEventShowCharacterActionsUIListener,
IGameEventCharacterStatsModifiedListener,
IGameEventCharacterTurnCompleteListener
// Character turn complete
void IGameEventWithTypedDataListener<IGameEventCharacterTurnCompleteListener, Character>.OnEvent(Character data)
{
}
What does _eventProcessor.AddListenerWithData do? Looking in the GameEventProcessor class, here’s the signature:
public void AddListenerWithData<TListener, TListenerData>(IGameEventListener listenerInstance) where TListener : IGameEventListener
The method uses the type of the listener (TListener) to read the given [ListensToGameEventType(typeof(YourGameEvent))] attribute we saw above:
Type gameEventWithDataType = GameEventListenerUtils.GetEventTypeForListenerType<TListener>();
public static Type GetEventTypeForListenerType<T>() where T : IGameEventListener
{
Type listenerType = typeof(T);
if (!ListenerTypeToEventType.TryGetValue(listenerType, out Type eventType))
{
ListensToGameEventTypeAttribute attribute = listenerType.GetCustomAttribute<ListensToGameEventTypeAttribute>(false);
if (attribute == null)
{
Debug.LogWarning($"A game event listener of type `{nameof(T)}` is missing a corresponding game event. Please specify it as an attribute in its class.");
return null;
}
eventType = attribute.TypeGameEvent;
ListenerTypeToEventType.Add(listenerType, eventType);
}
return eventType;
}
From there we store it in a dictionary:
private readonly Dictionary<Type, List<IGameEventListener>> _gameEventWithDataListeners = new();
Where Type is a game event type, and List<IGameEventListener> are their listeners.
When we raise a game event:
_eventProcessor.RaiseEventWithData(new GameEventCharacterTurnComplete(character));
The type of the game event (GameEventCharacterTurnComplete) is used as the key, which provides us with the list of bound listeners.
The difficulty now is that the dictionary returns a List<IGameEventListener>, but IGameEventListener is the base interface, not the one with specifically typed data.
We can’t switch it to the typed one, say (IGameEventCharacterTurnCompleteListener) because the dictionary can’t hold all these different listener types. There's no such thing as a Dictionary<Type, List<IListenerOne> OR List<IListenerTwo> OR List<IListenerThree> OR... > There’s one type for the key, and one type for the value. Only an all encompassing base interface will do.
How do we send typed event data to an untyped interface?
We use a second dictionary. We map each IGameEventListener instance to all types of game events it listens to, and furthermore, the response (stored as a lambda) it should invoke when that game event type is raised.
private readonly Dictionary<IGameEventListener, Dictionary<Type, Action<object>>> _dataListenerToEventRaises = new();
Now we raise the game event, iterate through the List<IGameEventListener>, take each listener instance, and then invoke that lambda (Action<object>) with the supplied data.
_dataListenerToEventRaises[listenerWithData][gameEventWithDataType].Invoke(gameEventWithData.RawData);
The question then becomes, how do we build this lambda? This happens back in AddListenerWithData. We don’t just store the listener in a list to be called, we create the delegate that is invoked when the listener is called.
// Each listener implements OnEvent(T data) supplying a concrete type (ex: OnEvent(int data) or OnEvent(Character data)). Get the MethodInfo behind this typed method.
if (!DataListenerTypeToEventRaiseMethodInfo.TryGetValue(listenerType, out MethodInfo eventRaiseMethodInfo))
{
eventRaiseMethodInfo = GetTypeOfImplementedGenericInterface(listenerType, "IGameEventWithTypedDataListener").GetMethod("OnEvent");
DataListenerTypeToEventRaiseMethodInfo.Add(listenerType, eventRaiseMethodInfo);
}
// Create a delegate that calls that method using the listener instance
Type delegateType = typeof(Action<TListenerData>);
Delegate closedDelegate = eventRaiseMethodInfo.CreateDelegate(delegateType, listenerInstance);
// Wrap that delegate in an all-encompassing Action<object> to be stored in a dictionary. But perform casting inside to achieve strong typing
eventRaises.Add(gameEventWithDataType, data => ((Action<TListenerData>) closedDelegate).Invoke((TListenerData) data));
Where GetTypeOfImplementedGenericInterface looks for the interface that supplies the typed IGameEventWithTypedData.OnEvent(TData data) method.
private static Type GetTypeOfImplementedGenericInterface(Type type, string interfaceName)
{
foreach (Type interfaceType in type.GetInterfaces().Where(i => i.IsGenericType))
{
if (interfaceType.GetGenericTypeDefinition().Name.StartsWith(interfaceName, StringComparison.OrdinalIgnoreCase))
{
return interfaceType;
}
}
return null;
}
Using reflection, we retrieve a reference to the MethodInfo of the game event listener’s typed OnEvent(TData) method. Using that MethodInfo and the listener instance, we create a delegate calling the method using the listener instance. Finally, remembering that our dictionary below:
private readonly Dictionary<IGameEventListener, Dictionary<Type, Action<object>>> _dataListenerToEventRaises = new();
can only take one type for a key and one type for a value, each lambda needs to be an Action<object> to cover every possible type of data that can be raised. We wrap the strongly typed delegate in a Action<object> using casting.
Therefore each time the listener receives data, this lambda will run, casting the object data to the correct concrete data, and the delegate to its proper type. While no casting would be ideal, these two casts are significantly more performant than relying on any sort of reflection call.
Of course we are using reflection, but these reflections calls are entirely confined within the binding operation, which happens a single time, and not the event raise operations, which happen often. To relate it in Unity terms, our expensive operations are done in Awake and not Update. Additionally, we can do things like cache any MethodInfo's we retrieve, as these are instance-less.
The tradeoff is that our confined use of reflection has granted us typed event responders, which makes it clear what type of data each responder is receiving. Additionally, if that data type changes, every receiving method will report a syntax error (the types no longer match), and this ensures we keep everything in sync. We can confidently refactor without risking breaking connections.
This tradeoff is something my game can handle. It is mostly discrete events that are not raised every frame. For example, a button press raising an event and causing two casts will not grind my game to a halt. (Or if it did, there are probably other areas of the program that are better optimized first.)
Other implementations
In this part, I would like to go over two other implementations of event systems I have done.
The first approach a simplified untyped version of this. We maintain a Dictionary<EventEnum, List<Action<object>>>, and bind to the event:
_eventProcessor.AddListener(EventEnum, OnEvent)
private void OnEvent(object rawData)
{
TypedData data = (TypedData) rawData;
}
Here we see that if the event represented by EventEnum decides to switch its data to another type (say TypedDataTwo), the cast will silently fail at runtime unless we remember to change it. There will be no syntax error beforehand. There is no real connection between EventEnum and its data, except what is known in our minds. The more we can rely on the computer and not the human element to catch our errors, the stronger our code will be. Though if our game is small, this becomes acceptable.
The second approach used ScriptableObjects as the event. Each ScriptableObject represents an event with a Raise(TData data) call and maintains a list of listeners. You can add listeners to the ScriptableObject with an AddListener(Listener listener) call. Therefore, by serializing a reference to the ScriptableObject asset in two different MonoBehaviours, one can listen, and the other can raise an event.
This works well and cleanly, but ScriptableObjects are Unity assets. I began to run into problems with different asset bundles referencing different copies of the same ScriptableObject. While two MonoBehaviours appeared to reference to the same asset in the inspector, they were actually two different copies. This can be remedied by a proper asset bundle setup, but at this point, the event system is no longer independent. It has silent demands on your project architecture, and comes with an instruction manual to set up (what started as an event system now demands proper asset bundle initialization code). Additionally, to access the ScriptableObject requires a [SerializeField] and a MonoBehaviour, which sometimes, you wish to listen to an event or raise an event in a plain old C# class, especially working with external (non-Unity) libraries.
The event system written above can be dropped into any Unity project, used in any class, anywhere, and work. And that gives me piece of mind.
Addendum
After posting, there were several improvements to be made, including removing the need for most reflection by passing better type parameters.
For example, using this method signature:
public void AddListenerWithData<TListener, TListenerData>(TListener listenerInstance) where TListener : IGameEventWithTypedDataListener<TListener, TListenerData>
instead of:
public void AddListenerWithData<TListener, TListenerData>(IGameEventListener listenerInstance) where TListener : IGameEventListener
Allows us to skip delegate creation through reflection. (We already posses the instance with the method, we don't need to find and invoke it through reflection):
eventRaises.Add(gameEventWithDataType, data => listenerInstance.OnEvent((TListenerData) data));
This reduces the cost of raising a data event to one cast, and one without data to zero casts. No reflection outside of one attribute lookup per game event type. Additionally, the code has been simplified. (Though the initial solution is close, and interesting in its roundabout way!)