A Gameplay Programming Puzzle for ECS
Entity Components Systems are hot stuff right now in the world of game engine design. Most of Unity’s new engine workflows is based on ECS, and there are other junior up-starts like the open-source Bevy Engine. They’re extremely fast, and well suited to today’s machines with many cores. But they can be a real pain sometimes when implementing gameplay code.
Every now and then, I stumble on a gameplay pattern that’s particularly challenging to implement within an ECS architecture. When that happens, I like to try to simplify the problem – distill it down – and save a draft for posterity. These simplified puzzles are fun because they’re tractable to think about, but they still derive from real-world gameplay needs!
It’s great practice for breaking the bonds of OOP and thinking of the world in a data driven way, and they also provide a sort of ‘design benchmark’ for any promising ECS coming onto the scene. If you can’t implement these patterns with your ECS, it’s pretty likely you’ll run into problems as your games get bigger and more complex!
Let’s dig into one I hit recently, which I’m calling the Pet Adoption:
Pet Adoption
Here are the rules of the (extremely simple) game.
There are many Humans and many Pets.
Each human adopts the first pet that likes them.
Pets may arbitrarily decide if they will let a given human adopt them.
Each pet can only be adopted once, and each human only adopts one pet.
Gameplay goes as follows:
Each human goes from pet to pet, asking them if they’d like to be adopted.
They leave with the first pet that says yes.
We run our adoption game every frame.
—
There’s no concept of ranking here: pets will take the first human that suits them! Similarly, humans will take any pet that allows it!
The thing that makes this tricky is that you may have 50 different types of pets, all with different logic for determining which humans they like. For example, when asked to be adopted, they might:
Read from the human state (that person is too ugly!)
Read from their own state (I’m scared and don’t like anyone!)
Read from global state (It’s cold today, so I’ll take anyone!)
They could even modify their own state based on the previous humans. Maybe the first few humans are so unpleasant that a pet decides to say no to everyone.
This problem is fundamentally one of indirection, usually solved by polymorphism. With OOP, this is trivial: add a function to the Pet base class bool CanBeAdopted(Human adopter)
and call it as humans are inspecting different pets.It’s no wonder UI libraries tend to heavily make use of OOP. ECS generally bans this entirely. Components should be plain, blittable data. Systems should contain your logic.
Instead, other types of decoupling are suggested, such as event systems. However, those don’t work here. Event systems are one-directional: you fire off an event and expect another system to handle it. This gameplay requires bi-directional coupling: a human needs to know a pet’s response first, so it can move on to the next pet in line.Tag Components are another way of handling indirection. For example, adding a “Collision” component to any objects that collide with something. However, that’s also one-directional and fails for the same reasons.
The human can’t wait for this information – it needs to know now! This is the distinction between events which send information, and callbacks which send information and retrieve results.
So it’s tricky. ECS asks you to queue up big chunky systems and pipeline everything, but this logic needs to reflect on objects in the moment, while a human is traversing the list of pets.
So, how would you architect this?
(Discuss on /r/programming)
Real World Context
If you’re curious how this maps to the real world, here are two examples.
Object Interaction: Characters (Players or NPCs) can interact with objects in the game, but the object chooses whether it will allow a specific character to interact with it, depending on arbitrary factors (time of day, weather, character stats, etc). Certain chests may only open for players, and not NPCs. Or only if the player has <500 gold in their inventory.
Essentially, each object has a ‘shot’ at the interaction event, but can choose to reject it, and the character will move the next valid thing in front of it. This continuation is important, otherwise when an object rejects the interaction event, it could swallow the interaction for a perfectly valid object sitting on top of it.
Characters are humans, Pets are objects, and adoption is whether the interactable allows a certain character to interact with it.
User Interface: UI systems tend to have big trees of elements. We want to be able to propagate events down the tree, allow elements to handle them in arbitrary ways, and also return whether an element has “consumed” an event. Event handlers are bidirectional: they affect the propagation of the event itself.
The UI systems are humans, layout elements are pets, and the consumption return value of the event handler is a pet deciding whether to be adopted.
This pattern tends to occur in any system that has single-ownership. Ie. Where several different entities in the game need to agree on who is assigned which other objects.