Events in Entity Component Systems

Entity Component Systems (ECSs) have become a popular method to organize engine logic in games. One reason is performance: Running the same code on different data is extremely efficient for modern CPUs. Another reason is engine architecture: Splitting up the properties of entities into components allows for a pleasant separation of concerns as well as logic, and can lead to a high degree of composability if done right.

Since ECSs are different from more traditional (OOP) approaches, it is not always obvious how to transfer common game engine tasks to this new approach. One such thing I stumbled upon were events. Events occur all the time in games, and are an important part of game logic. Common examples include:

  • An entity has just collided with another entity, and we want to, e.g., deal damage or trigger another gameplay effect.
  • An entity has just died, and we want to trigger logic based on that.
  • Our protagonist has just picked up an item, and if it’s a quest-relevant item, we need to progress the quest.

And the list goes on. Events are incredibly common in games, especially in dynamic and extensive ones.

In this post I want to present what I learned and ended up with when integrating events into an ECS.

Events as Function Calls

The big challenge with events in ECSs is that you also have listeners, which react to a triggered event. In an ECS, you want all logic to be within systems, and all data to be within components. If you suddenly start throwing mysterious “events” around, and have your logic elsewhere than only in systems, you quickly lose a lot of the benefits of ECSs. Also, what happens if a listener adds another listener (possibly for the same event?) while running? Concurrent modifications of data structures are usually undesirable and tricky, and the gameplay-logic related implications of that are also daunting.

Instead, I ended up modeling events as function calls. Instead of iterating over a list of listeners, calling each one with the triggered event, adding and removing listeners when appropriate, I chose the following:

world.on_collision_started(collider, collided);

And on_collision_started then looks something like:

fn on_collision_started(collider: Entity, collided: Entity) {                
spike_damage::collision_started(collider, collided);
cinematics::collision_started(collider, collided);
... and so on
}

All possible listeners are in a fixed order inside the world’s on_collision_started method, immediately making all possible effects a collision can have obvious, as well as the order in which they are checked and executed inside the engine.

This pretty much makes the main update of an ECS, the part where all systems process their relevant components, nothing else than another event, e.g.:

world.on_update(delta_ms);

With on_update then similar to:

fn on_update(delta: Time) {
velocity_system.update(delta);
collision_system.update(delta);
... and so on
}

The similarity to what ECSs already do for updates should become obvious by comparing this to the on_collision_started method from before (I also found this idea mentioned somewhere else on the internet).

Moreover, treating events as function calls leaves the logic inside the systems: They are just functions that a system can call if it triggered a corresponding event, and they can be reused by all existing systems.

To see why this might be a good idea, let’s take a look at a different way to model events, that also seems to fit well with ECSs on first glance: Modeling events as components.

Events as Components

All data in an ECS is inside components, and all logic inside systems, so another idea would be to put events that occurred inside components. Systems would then detect events when they execute their update logic, and at the end of the loop events would be cleared (e.g., a OnCollisionStartedEvent would become anOngoingCollision ).

But this has a disadvantage: Now, all systems that ran before the event being created never got a chance to see it in this timestep! So instead we have to let the event live until it reaches the system that caused it in the following timestep, which can then clean it up. This has two disadvantages: First, the system that caused the event now needs to remember which events it needs to clear, which can quickly become annoying.

However, the bigger disadvantage is that this adds a delay for all previous systems that react to the event, since they can only do so during the next timestep. This effect also stacks: If handling this event causes a new event to be created, handling this event is then also possibly prolonged into the next timestep, and so on.

I noticed that it can be helpful to think about these things in terms of the logics of the game you create. Treating it this way, you form the laws of physics and causality inside your game in a way that causes an entity to take damage a few milliseconds/seconds in gametime after it stepped into some spikes. Is this what you intended? Otherwise, fitting a concept into your engine has just become a major factor that determines the logic of your game.

This — it seems to me — is something that is better avoided, and chosen deliberately when it fits into the game itself.

I am sure there are other ways people handle events in ECSs, but I didn’t arrive at a better way to model them than to simply use function calls. If you have a better idea, please let me know!

Let’s take a look at other implications this design has for ECSs.

Implications for ECSs

As already mentioned before, ECSs usually have two main advantages: One, a clear separation of concerns, and second a performance benefit. While modeling events as function calls clearly defines what happens in reaction to an event, and in what order, handling an event can now touch on possibly every data and component inside the world.

This deviates from the primary goal of ECSs to build efficient systems by executing them on the same kind of data over and over, only for different entities. Is this trade-off worth it? It could be, especially because it also brings extremely good code organization and a clear design with it. So the answer here is probably: Decide based on the game you make. If you are extremely dependent on performance, you will probably have to make a lot of special design choices anyways.

Network Synchronization? RPCs!

One other benefit of ECSs is that they allow for “simple” network synchronization, because all that needs to be done is synchronizing all components between all hosts. In practice, of course, creating a networked game is an immense task in itself, and never as simple as “just synchronizing data”.

By modeling events as function calls we can model client-only logic that needs to be executed as Remote Procedure Calls (RPCs). These have become more and more standard in web-based services. Basically, they model calling a function on a different endpoint. This way, we can get away with only synchronizing components, and sending RPCs to trigger client only logic (such as fancy effects, etc.).

It might be tricky to run the RPC at the right point in time, or deliberately move it to the end of an update step. In a way, sending an RPC is very similar to transmitting an event. I’m not 100% clear yet about the implications of this, or whether this could be a reason to aim for a different design.

Scripting Engines and Extensibility

Modeling events as function calls seems to make everything very static, and game engines/games often want to be extensible by script engines. This has a lot of benefits, one being that you do not need to rebuild your entire binary if some minor logic in a single level has changed somewhere.

One way to reconcile the two would be to allow external scripts to modify the pipeline of functions that are executed for an event, either only when the world is initially created, or dynamically. This would require the design to be slightly changed, but there should be no inherent problems with this. I have not given this too much thought, but so far it seems doable to me.

The case for SPECS (and other parallel ECSs)

SPECS is an ECS that takes advantage of the fact that different systems often process different components, and from that derives a schedule that executes systems in parallel, efficiently making use of the capabilities of modern CPUs. To do so, each system has to declare all components it will (possibly) want to access.

Modeling events as function calls means that a system that triggers an event will possibly touch any component inside the world. For SPECS this would mean that parallel scheduling might become quite undoable, as all systems might touch on all components. A solution here would be to instead parallelize inside the event-handling function (remember how world updates are basically just another event? :) ).

Conclusion

So, that’s it! I could not find a good way to model events in ECSs, and I did not find much written on this subject on the internet either. Modeling events as function calls is my solution to this challenge. I am interested in all feedback from people who have faced (and solved) this problem before, and also in further implications this has for ECS design. Cheers!

Discussion on r/rust_gamedev: https://www.reddit.com/r/rust_gamedev/comments/9nmx1f/events_in_entity_component_systems_includes_talk/

Discussion on r/gamedev: https://www.reddit.com/r/gamedev/comments/9nvqaw/events_in_entity_component_systems/