Breaking old habits and trying to form new ones
February 2, 2024
Tagged: ECS Dinghy Architecture
I’m starting up making a small game with Dinghy to dogfood some of its concepts, and part of this is to actually try and use the ECS system instead of just programming games the old way.
I actually started trying to do stuff the old way and quickly started to find myself in event callback hell and thought “I think I should try and actually use ECS”.
One of the first things I ran into was managing collision. Dinghy provides simple entity callbacks for basic collision handling, but managing all of the state around collisions (start/stop/continue) isn’t currently handled so you end up doing a lot of weird state clearing and management in non-gameplay code.
So I thought I’d take a whack at managing collision stuff in a Better Way and think I figured it out. I’ll likely roll some of this into the engine itself because it’s generic enough, but here’s the gist of how to do ECS-centric collision in Dinghy.
First off, a big thing I was looking at were Events. Collision systems are basically just callback handlers about some internal collision processing logic, so events made sense to work with first.
For events, traditionally in games I’ve done something like
public static Action MyEvent;
//some other class
void Create() {
Events.MyEvent += MyResponse
}
void Destroy() {
Events.MyEvent -= MyResponse
}
void MyResponse() {
//response code
}
This works generally (all of Cantata basically works like this), but it has some downsides. Namely, that “state” ends up being by necessity distributed amongst a lot of listener objects. These objects either maintain their own inner state or report back somehow (via an event most likely) to some other object to indicate they are updated. Additionally, because they accept incoming events that alter state, they probably need some additional event listeners that can undo or clear that state. This starts to approach the “callback hell” downsides of an event-driven architecture.
The way to do it right is to enforce really strict rules around who can emit an event and when, but that can only really be enforced externally and is hard to codify in the codebase itself. So how can we do this in ECS to make this better?
Well first, one thing that is difficult about events in a traditional event driven system like above is that events aren’t actually “objects” in the traditional sense, and need to be explicitly bound to code (they are just delegates). This is okay for small stuff, but what this means is that invocations are very “use it or lose it”. What would be nice is if there was a way to basically pump events into some sort of “system” where they kind of bounce around until we decide we want to consume and use them, use them without consumption, etc.
I was reading about Bevy recently and saw this:
When you register an event type, Bevy will create an
Events
resource, which acts as the backing storage for the event queue. Bevy also adds an “event maintenance” system to clear events every frame, preventing them from accumulating and using up memory.
This aligns with some other discussion I had on a Discord about ECS design - in ECS, events are just entites. You basically create an entity that is an “event”, and then tack on systems to handle it, and dispose of it when you’re done! This main sound kind of brain-bendy and a lot of code up front, but I’ve now implemented this in my game and I can already see some massive upsides. So let’s look at some code.
Because we’re in ECS land, we’ll first start creating some components that will define the Event:
public abstract record Event();
public record struct EventMeta(string eventType, int index, bool dirty = false);
public abstract record ActionEvent() : Event;
public record ButtonClicked() : ActionEvent;
I haven’t really talked about ECS creation in Dinghy, but the key thing to mostly know is that “Components” can be effectively any type, and ideally the components are as small as possible. I make the base Event
class be a record so I can extend it (ActionEvent
). I have one “concrete” event in the form of ButtonClicked
and then also have that EventMeta
thing.
What’s EventMeta
? This is a sort of pattern I’ve come to understand when working with ECS stuff. A KEY thing about ECS is that it gets its speed/power from precise memory layout of entites/components. This sort of implicitly means that having “generic” component types that then get subclassed to specific types is bad, as a List of those types would need to be boxed in memory to accomodate the biggest type, etc. TLDR is that, in ECS, polymorphism is bad, and instead you want to favor composition.
EventMeta
(or “Meta” components in general) are a way to both work with that idea and also get around the limitation. Instead of putting common data in a base class and extending it, you just create a Meta class that tracks common data, and then can make hyper specific components that add in whatever else you need. This is also practically relevant for working with the S (“Systems”) of ECS with how querying works, which I’ll talk about now.
For fast querying in ECS (querying being “find entities that have these components”) you can’t use polymorphism. If you try to find all entites that have and Event()
component, you will not find entites that have a subclass of that component! As much as this is a limitation, it also is philosophically aligned with the idea of ECS, namely, that if you are querying, you are querying because you want to handle a specific thing, not handle all possible events. But if you DO want to handle all possible events, you can use something they all have in common…EventMeta
!
So now we’ve got some components, how can we make them an entity? Well as a reminder, Dinghy uses Arch for it’s ECS backend, so anything in this repo is fair game for use inside of Dinghy. We’re going to just use a basic entity creation function and use the “world” that Dinghy provides:
public static int EventCounter = 0;
public static void Raise<T>(T e) where T : ActionEvent {
Engine.ECSWorld.Create(
new EventMeta(e.GetType().ToString(),EventCounter),
e);
EventCounter++;
}
Seems easy enough - we’re just creating an entity in the Engine.ECSWorld
with those components. We call e.GetType().ToString()
as a sort of hack but also debug way to log what events we eventually process.
We can now create an event somewhere in code like this:
Raise(new ButtonClicked());
Now how do we process them?
Through Systems! Arch provides a way to Query the world and get all entites that match the specific pattern you’re looking for. Here’s a basic system where we can query for our events:
public class EventSystem : DSystem, IUpdateSystem{
private QueryDescription events = new QueryDescription().WithAll<EventMeta>();
public void Update(double dt) {
Engine.ECSWorld.Query(in events,
(Arch.Core.Entity e, ref EventMeta m) => {
Console.WriteLine(m.index + " " + m.eventType);
});
}
}
To actually have this run, elsewhere in our code (like in Create()
in a scene), we have to register this system:
Engine.RegisterSystem(new EventSystem ());
Running all this together, you should see the console logging out lots of “ButtonClicked” events! Awesome! What next?
Well, it’s likely that in your game you don’t want a system that just looks at generic events - you want to query for specific events. You can pretty easily update the same query to instead grab any event that explicitly has a ButtonClicked component:
public class EventSystem : DSystem, IUpdateSystem{
private QueryDescription events = new QueryDescription().WithAll<ButtonClicked>();
public void Update(double dt) {
Engine.ECSWorld.Query(in events,
(Arch.Core.Entity e, ref EventMeta m) => {
Console.WriteLine(m.index + " " + m.eventType);
});
}
}
You can imagine that in this system you could do things like playing a sound, doing an animation, etc.
However! You may see a bug here. Once we raise this event, it will ping 60 times a second in our game. This is bad! What we want is a way to “cleanup” our event after a frame in order to not have it emit continually. To do that we can make a Dinghy “Cleanup” system that can handle this!
public class EventCleaningSystem : DSystem, ICleanupSystem {
private QueryDescription events = new QueryDescription().WithAll<EventMeta>();
public void Cleanup() {
Engine.ECSWorld.Query(in events,
(Arch.Core.Entity e, ref Events.EventMeta m) => {
if (m.dirty) {
e.Add(new Destroy());
}
else {
m.dirty = true;
}
});
}
}
What we do here is that, when an event hits this system for the first time, it will mark the event as dirty. This gives the event the ability to exist across frames and make sure it hits any system it is meant to hit. Once it comes back to this system, because it is marked dirty, we add the Destroy component to it to tell Dinghy to properly Destroy the event entity at the end of the frame.
You also don’t have to wait for the cleanup system to mark an event as dirty. If you raise an event somewhere and the system you want to listen to that event gets that event and handles it, you can just mark the event as dirty and it will be cleaned up at the end of the frame.
This can get us pretty far with events that are “one time” things like clicks, but what about events that have “state”, or more specifically, how do we handle collision events like Start/Stop/Continue?
Well first lets make some new components, which will make more sense below.
public record struct CollisionMeta(int hash, CollisionState state = CollisionState.Starting);
public record CollisionEvent : Event;
public readonly record struct EnemyComponent(string name);
public record EnemyCollision(EnemyComponente) : CollisionEvent;
Ignore the parameters for EnemyCollision(the event and params can be whatever you want), but one thing to note is that for my collision, im adding in an additional meta component, CollisionMeta
that tracks the state of my collision. For the same reasons about, this can’t just be on the base “Collision” class, as we want to be able to query it directly. We’ll also introduce a new Raise event that takes an additional int parameter (we’ll explain hash below):
public static void Raise<T>(T e, int hash) where T : CollisionEvent {
Engine.ECSWorld.Create(
new EventMeta(e.GetType().ToString(),EventCounter),
new CollisionMeta(hash),
e);
EventCounter++;
}
We now create the collision system. I’ll post the whole thing below and then talk about the relevant bits after.
public class CollisionSystem : DSystem, IUpdateSystem
{
QueryDescription query = new QueryDescription().WithAll<Active,Collider,Position>();
QueryDescription colQuery = new QueryDescription().WithAll<Events.EventMeta,Events.CollisionMeta>();
private List<(Arch.Core.Entity e,Collider c,Position p)> colliders = new();
private Dictionary<int, Events.CollisionEvent> bufferedCollisionEvents = new();
public void Update(double dt) {
colliders.Clear();
Engine.ECSWorld.Query(in query, (Arch.Core.Entity e, ref Position p, ref Collider c) => {
if(!c.active){return;}
for (int i = 0; i < colliders.Count; i++) {
if (e.Id != colliders[i].e.Id && Dinghy.Collision.CheckCollision(c,p, colliders[i].c,colliders[i].p)) {
if (colliders[i].e.Has<DataTypes.EnemyComponent>()) {
var hash = HashCode.Combine(e.Id, colliders[i].e.Id);
if (!bufferedCollisionEvents.ContainsKey(hash)) {
bufferedCollisionEvents.Add(
hash, new Events.MouseEnemyCollision(colliders[i].e.Get<DataTypes.EnemyComponent>()));
}
}
}
}
colliders.Add((e,c,p));
});
Engine.ECSWorld.Query(in colQuery,
(Arch.Core.Entity e, ref Events.CollisionMeta cm, ref Events.EventMeta em) => {
em.dirty = false; //keep the event alive
if (bufferedCollisionEvents.ContainsKey(cm.hash)) //if we have buffered a collision that already exists {
cm.state = Events.CollisionState.Continuing;
bufferedCollisionEvents.Remove(cm.hash);
}
else {
cm.state = Events.CollisionState.Ending;
em.dirty = true;
}
});
foreach (var e in bufferedCollisionEvents) {
switch (e.Value)
{
case Events.MouseEnemyCollision specificEvent:
Events.Raise(specificEvent, e.Key);
break;
default:
throw new InvalidOperationException("Unhandled event type");
}
//this doesnt work and instead assumes the base type - maybe a Arch bug?
// Events.Raise(e.Value,e.Key);
}
}
}
The major idea here is twofold. The first is that, when two objects collide, their entity IDs will be unique, and hence the hash between those ids will be unique. This means that there is a unique hash for any two objects that may collide that is also consistent across frames.
Our first query is where we buffer all current collisions based on this idea:
if (e.Id != colliders[i].e.Id && Dinghy.Collision.CheckCollision(c,p, colliders[i].c,colliders[i].p)) {
if (colliders[i].e.Has<DataTypes.EnemyComponent>()) {
var hash = HashCode.Combine(e.Id, colliders[i].e.Id);
if (!bufferedCollisionEvents.ContainsKey(hash)) {
bufferedCollisionEvents.Add(
hash,
new Events.MouseEnemyCollision(colliders[i].e.Get<DataTypes.EnemyComponent>()));
}
}
}
Next, because this system also processes existing collisions that have already been emitted, we then query for existing collisions. Based current buffered collisions, we know two things:
We then update the CollisionMeta appropriately (and EventMeta) for the already active collisions, and remove the collision from the list of buffered collisions.
if (bufferedCollisionEvents.ContainsKey(cm.hash)) //if we have buffered a collision that already exists {
cm.state = Events.CollisionState.Continuing;
bufferedCollisionEvents.Remove(cm.hash);
}
else {
cm.state = Events.CollisionState.Ending;
em.dirty = true;
}
At the very end, we have a list of buffered collisions that haven’t been handled at all yet, which we now know are verifiably “new” collisions. We emit those with Raise, passing in their unique hash. One thing you’ll see here too is that it’s either an Arch bug or feature, but you can’t polymorphically pass in a component to World.Create and it create the subclass type, so for now (hopefully not too long) you have to do this switch pattern matching thing to coerce the Raise function to have the explicit type to pass to Engine.Create:
foreach (var e in bufferedCollisionEvents) {
switch (e.Value)
{
case Events.MouseEnemyCollision specificEvent:
Events.Raise(specificEvent, e.Key);
break;
default:
throw new InvalidOperationException("Unhandled event type");
}
//this doesnt work and instead assumes the base type - maybe a Arch bug?
// Events.Raise(e.Value,e.Key);
}
And that’s it!
Though this is harder up front than just throwing some Actions
on a static class, you can hopefully see how easy it is to extend this. Adding new events is just a matter of extending your event class and raising them, and they will then pump through all the same logic paths as everything else and can be handled appropriately in systems that “listen” for them.
All in all, I’m really glad I took the time to start making a small game with Dinghy and trying to actually use the ECS stuff. Even in just this small microcosm I feel like I learned a ton and am starting to really see and understand the benefits of an ECS-architected game. Hope this sample code also helps out other people trying to use ECS stuff in Dinghy. As I mentioned above, I’m honestly considering moving more of the engine’s core event handling to just operate like this by default. I mentioned Bevy above, and it’s similar for them where all the event piping is already setup and you just create and raise events into it. I think with some C# automagic sugar on top it could be something really special! Ideally you could do something as simple as:
record MyEvent : DinghyEvent;
void SomeClass() {
Raise(new MyEvent());
}
[EventListener<MyEvent>]
void Listener() {
//code that happens when MyEvent is raised
}
Here’s to hoping for that future!
Published on February 2, 2024.
Tagged: ECS Dinghy Architecture