Implementing a Turn-Based Game in an Entity Component System with SPECS-Task
I’ve heard from a few people who are just getting started with entity component systems (ECS) that implementing logic for a turn-based game seems more complicated than it should be. I thought that seemed odd, but I just recently ran into this problem myself. While certainly not insurmountable, implementing turn-based logic in an ECS just doesn’t feel great. I think the reason is that no one likes to implement a loop via distributed state machines.
Consider this pseudo-code that you might expect for a turn-based game.
current player = first player
while game is not over:
actions = current player decides what to do
for action in actions:
player does action
current player = next player
That’s super simple! But wait, how would you do this in an ECS? Well… each of your players (or NPCs) is probably an entity. Then each action they could do is probably a component that’s only present while they’re doing that action. Then for a sequence of actions they’ll need another component. Oh and you’ll need a system for each of those components to run through the state machine of doing that action and presenting it to the player in a nice way. Each of those systems probably has to wait on some core subsystems to finish animations, physics updates, etc. Don’t even get me started on letting multiple players take their turns at the same time! You start to see how it’s state machines all the way down.
This isn’t totally surprising though. State machines are essential! They’re just not as nice as our higher-level abstractions that we used in the pseudo-code. Loops! Why can’t we loop like that in our ECS?
The reason is that an ECS is a set of systems that dispatch independently; you can’t tell system B to run while you’re already inside running system A.
I’m currently making a game using the Amethyst engine, which (as of writing) uses the SPECS ECS crate. There you have
Systems which are essentially just functions with access to some set of
Resources called a
SystemData. All of the systems get bundled up into a
Dispatcher which schedules them to prevent concurrent access to mutable resources. Each
System gets to run only once on each call of
Dispatcher::dispatch. Only the dispatcher gets to decide when systems run! Because of this model, you can’t wait for system B while inside of running system A; that would cause a deadlock.
One thing you can do is send an event to system B to tell it to do something. Then system B can message you back when it’s done. But this isn’t really any better than what I described above. The systems still have to care about intercommunication and manage the state of entities relative to the events that concern them.
Can we do better? I think so. In fact, there is already a programming paradigm that fits our situation rather well: multitasking! With task systems and futures, you can compose all of the tasks you want, forking and joining to create a graph of operations that take big chunks of time to complete.
But how can multitasking work in an ECS? You can’t just spawn a task in a closure that needs access to some
Resources. Those Resources are locked down by the
Dispatcher. And how would you even join a task if you can’t block? It’s true that we have to work inside of these limitations. All tasks have to run in some
System. And we really can’t just block in the usual sense.
Generally any time you want to do some work in SPECS, you need access to some
SystemData. And if you want that work done in the background of your current context, you need a separate
System to do that work. If the work must be done over several iterations of a
System::run, then it makes the most sense to put that work on a stateful resource, like a
Component of some
Entity. This concept is encoded in a simple
TaskComponent trait, which is essentially a
Component that gets to run like a
System, but only when it needs to.
Then the system that runs your task works like this.
Now we at least have a generic way of running background work. Just implement the
TaskComponent trait for your type of work, and create a component of that type on some entity. (Don’t forget to register your
TaskRunnerSystem<T> on the
But what is the
TaskProgress being used there?
This is a bit of metadata stored in a component on each task entity. It tracks whether the task is blocked, running, or complete. To allow for task parallelism, the
TaskProgress implements interior mutability with an
Remember the graph that represents task ordering? This is represented as a
MultiEdge component that stores the entity IDs of children tasks which must complete before their parent can run. All tasks start blocked, and there is a separate system to unblock them, called the
TaskManagerSystem. This is where the proverbial magic happens. The
TaskManagerSystem traverses the task graph to see if any tasks can be unblocked. If you’re interested in seeing how this works, take a look at the source code here.
I’ll leave you with a real example of how I’m using the specs-task crate in my own game, which is in fact not turn-based, but allows every actor to play simultaneously in discrete steps. The task abstraction is nonetheless very useful.
ChoreographyData is an object that can take a script of actions and create the task graph that implements the proper ordering of the actions. In order to do this, it uses the
self.task_man to create and compose the tasks.
Notice how there are no state machines (if statements) required to specify the task graph! Just a declarative specification, and one if statement to check if all of the actions are complete.