Event Chaining as a Decoupling Method in Entity-Component-System
Joël Lupien (Jojolepro, jojolepro@jojolepro.com)
https://patreon.com/jojolepro
Context
In game engines, we often have a lot of dependencies between modules. For example, the user interface often depends on the renderer, window and input systems.This means if we are not careful, we will end up with a user interface that works only with a single type of input.When you add a new input method (a controller for example), then you have to also edit the user interface functionality to be able to use this new input device. What is surprising however, is that not only do we need to change how input works, but also we are modifying user interface code to add a device that... probably does the same thing we are already doing. This situation isn't unique to user interfaces. In fact, this is very often how dependencies are modelled.It is intuitively what we think of when we think of logical dependencies. A depends on B, thus A refers to B.As we have just seen however, it can cause problems of maintainability, because we have strong coupling.
Reducing Coupling
There are multiple ways to reduce coupling, depending on which paradigm you use.Here, I will be specifically exposing a way to reduce coupling in the context of an Entity Component System (ECS).
Fundamentals
First of all, let's see the building pieces that we have in a ECS.I will be using terminology from the Amethyst Game Engine, since this is what I am most familiar with.
Entities
Entities are "units" of the game. Each player, item, user interface element, audio source, etc.. are entities.We will not discuss much about entities in this paper.
Components
Components are properties of entities. Again, we will not be discussing those.
Resources
Resources are simple data that is stored inside of the ECS' context.
Systems
Systems are what drives the changes in the game. They are functions that take data from the ECS context (resources and entity/components), perform some computation and finally modify the ECS context.Systems can depend on each other to sequentially perform computation. If they don't explicitly depend on each other, they will be ordered automatically (and in parallel when possible) in a more or less optimal ordering depending on how they use resources or components (whether they read or write to them).If a system writes to a resource or component, no other system can access this same resource or component at the same time (in parallel).
Event Channels
Single writer/Multiple readers FIFO queues. Most data types can be inserted through those (they only need to be thread safe).You need a registered instance of ReaderId to read from them. ReaderId instances can be created by getting a mutable reference to an Event Channel and calling register_reader().
Additional Terminology
Let's introduce distinct names depending on how those previous concepts are used to improve the clarity of this paper.
Driver
A System used to "drive" the execution of another system, often through the use of Signals.
Signal
A Signal is simply an event written in an Event Channel that has for only purpose to drive the execution of one or multiple Systems. If we look at it the opposite way, some System will look for Signals as a way to know if and what kind of work they need to do.
Event Chaining
What Are They?
Very similar to the concept of message passing, event chains are a way to communicate between Systems.Simply put, you have one event that is created, which create a second event, which create a third event, which is then consumed (received) by a System.
Compared to Message Passing.
In traditional message passing, you often have some System A that sends a message to System B.We say that System A is "aware" of System B's existence. A way to decouple this is through the use of some method of broadcasting the message to anyone who is registered to receive it. This is how mailing lists work. Here however, we don't have a list of who should receive each message. Instead, each System that wants to receive the event has a ReaderId which it can use to read from the Event Channel. The reason for this is so that we don't have a single place where all of the message buffers are and where every system tries to get a mutable reference (which would heavily reduce performance).
How They Solve The Coupling Problem
If we continue with the example from the beginning, we can see a way to make the user interface unaware of the input system. - The Input System creates input Events (MouseClicked, KeyboardPress, ControllerPadLeft). - A Driver (System) converts input Events into user interface Signals according to a configuration structure stored as a Resource and other contextual Resources (are we inserting text? selecting user interface elements? dragging something?) - The User Interface System makes changes using a combination of - Signals (Press(x,y), InsertCharacter(char), SelectLeft) - ECS Entity and Components (Ui elements, like labels and text fields) - Resources (ScreenSize, SelectedUIElements) - The User Interface System creates Events based on the changes. For example, UiEvent::Clicked(label1_entity).
Drawbacks
Of course, we have to talk about drawbacks. Let me preface this with the following: There is a performance cost. It is small, but it is there. If we take the last example, we would now have one more System running (the UI Driver) and we would have to create the Signals for the User Interface System. In addition, in almost all cases of a Driver being present, there is a conversion step when converting events to signals, usually a HashMap lookup or similar. However, we surprisingly are also getting some performance gains from this. In the last example, instead of the User Interface System looking each frame for the status of the input device(s), we now have this System running only when signals are present.
Other usages
We have seen how this concept of Event Chaining allows to decouple user interfaces from input handling.Now, what else can we apply it to?Well... a lot of things actually. Here are some examples:
Asset Hot Reloading
- A FileWatcher System creates a signal when a file is updated on disk. Watched files are configured through a resource, which could itself be loaded from disk and hot reloaded. - A FileLoader loads the file from disk (triggered by the signal) and converts the data into a shared format. (RGBA for images, vector data for svg, vertices for meshes, etc) A Signal is then sent (DataLoaded, DataUpdated, DataDestroyed). - An AssetLoader picks up this signal and does some module specific action. For example, loading or updating a mesh into the GPU memory. In addition to simplifying asset reloading by dividing it in steps, it now also creates a way to notify system of data changes (a modified mesh) so that they can update their internal states (GPU memory).This gives more power to users, as they can now change data midway through without having to modify the code of what depends on this data (the renderer's mesh to gpu loader).
Audio Processing
- As described in the previous point, we have a system that can load files (including audio) and store them in a shared format. - We have a AudioPlayer Driver which will create Signals as time passes with audio data to play. For performance reasons, we can use references or indexes to the audio data instead of copying the data into the Signal. (AudioPlay{audio_data, from_point, speed}) - We have a AudioSink System that forwards the corresponding audio data into a raw audio sink.
Additional Benefit: Configurability
In this paper, I mentioned that Drivers can use configuration and context data from ECS Resources.This allows for a super easy way to configure complex behaviors. Let's take input as an example. The Input System creates raw events. The Driver converts those into signals (or events) for other systems to use. Let's see how we can use multiple Drivers to create complex behaviors. - UIDriver: Converts input Events into UI Signals. - UserDriver: Converts input Events to User-Defined Events. (KP_Escape -> UserEvent::PauseGame) - PlayerControllerDriver: Converts input Events to Player Movement Signals. (KP_Left -> Move::Left) - InputToAssetDriver: Converts input Events to FileLoader Signals. (KP_R -> FileUpdated(fun_file.jpg)) - WtfDriver: Converts input Events to ResolveWorldHunger Signals. (KP_P -> Please::SolveIt) As you can see, lots of Drivers are possible.Now we have two choices. Either the Drivers decide based on the context if they should create the signals OR they always create the signals according to their configuration.In the second case, the configurations can be changed by other Systems or by external code, depending on your preference.Personally, I find the second option more versatile, as it is rare that the Drivers can be made context aware in a way that fits all use cases.In this case, it is possible to have code that, for example, disables the UiDriver when the player is controlling their character, saving both performance and code complexity.
Conclusion
In conclusion, Event Chains are a powerful and versatile tool to decouple logical dependencies between Systems. They add a bit of complexity and performance overhead, but they are well worth their cost in the context of a general game engine.
General Recommendations
For those who already worked with something similar to EventChannel and ReaderId, you might have noticed that there are issues when a System that consume Signals is paused. When this happens, the System stops consuming the Signals and they stay in memory forever, causing memory leaks. The solution for this is to store the System's ReaderId inside of a Resource and to nullify/destroy it when the System gets paused. Testing! You can test Systems in isolation by manually sending Signals. Take advantage of this and test <3 Use generics! Drivers almost always do the same job: Convert one event type into another event (or signal) type by using a table or HashMap.This means that using generics to specify the input and output types as well as the HashMap key and value types, creating Drivers can be done in a single line. Document as much as possible the Systems. Since we have System and Driver which are both Systems and Event and Signal which are both Events, it can be quite confusing without the proper documentation to understand the role of each piece of the puzzle. Here are some ideas of what to document: - The configuration resources of each System. - The events that each System creates. - The signals that each System consumes.
Supporting Me
I released this for free/without limitations because I want to contribute to the greater good. If you like the work I do, please consider donating on Patreon: https://patreon.com/jojolepro Or by Bitcoin: 15NDruDUDr3KaMjt87BvUJaayEzy5c765Z