This post is a follow up to the article Logic over Time in which I described why new language features, such as coroutines, can help game programmers write more readable and robust state machines. In this article, I’ll talk in more details about the specifics of my implementation of coroutines and its advantages and uses beyond state machines themselves. Specifically, I'll talk about concurrency and synchronization.
I have posted the sources on GitHub, under an MIT license, feel free to use this framework in your own projects. The code is written in .NET 3.5 (the version supported by Unity).
https://github.com/jeansimonet/Coroutines
I will start by showing you an example, and highlight how these coroutines are nicely composable. After that I will dive deeper into the implementation of the framework, and answer some of the more common questions related to using coroutines in game code.
A Simple Turret
Picking up where I left off in the first article, I went ahead and wrote a simple Turret behavior for a mock game (sources of which are also available on the repository).
To summarize, the turret does the following: it looks for a target (the player) within a given radius. Once it finds a target, it does two things at once. It shoots projectiles, and it tracks the target. If it loses lock on the target (player moving too far away), it returns to its original orientation and starts over. Here is the relevant piece of code, let’s dissect it right away!
Let’s start by looking at the IEnumerable<Instruction> type of the Main() coroutine. As I mentioned in my previous article, this function generates an iterator block, that yields intermediate results of type Coroutines.Instruction. These intermediate instructions tell the framework how to proceed.
By default the framework enumerates the iterator block, which is what executes the actual coroutine code. It does this until a value is yield-ed by the coroutine code. Depending on what that value is, it will do one thing or another.
ControlFlow.ExecuteWhileRunning() and ControlFlow.ExecuteWhile() are of course special instructions that tell the framework what to do: mainly to execute other (sub)coroutines under certain conditions. Instructions like ExecuteWhile() or Call() are how we can compose coroutines, let’s look at them more closely.
Coroutine Instructions
To understand how instructions works, let’s start by looking at a simpler example:
This is the utility coroutine that waits for a specific amount of time. It just sits in a loop checking the time since it was first called, and yields null. Once the time has elapsed, it simply terminates. null is interpreted by the framework to mean ‘sleep until next frame’. (Note: it is the exact same meaning as it is for Unity’s coroutines. How convenient!)
A coroutine can also yield a Call instruction, passing in another coroutine. This is in fact what the FireProjectiles() coroutine of our turret does to wait between firing projectiles.
ControlFlow.Call(...) is a utility method that returns a derived class of Instruction, and has a special meaning that the framework understands. In this case it means ‘start executing the coroutine I am passing in (stored in the instruction), and resume me once it has terminated’.
As you would expect, there are other control flow methods that return different Instructions, which in turn have different meanings. ControlFlow.ExecuteWhile(...) is an example of that.
The ExecuteWhile instruction passes a number of (sub)coroutines and a predicate, and the framework understands it to mean ‘Run all the coroutines in parallel for as long as the predicate is true’. But before diving into the details of the ExecuteWhile(...) code, we need to take a step back and explain how the runtime works.
It’s always Graphs
Behind the scenes, the framework is building a graph structure. The runtime executes the user code, until the user code yields an instruction, and then the runtime interprets that instruction accordingly. Depending on the yielded instruction, the runtime can create different kinds of sub nodes. The most common coroutine node is the one that executes an IEnumerator<Instruction> iterator.
The graph is stored by the user however, by manually instantiating a root coroutine node. In our turret example, the root of the graph was declared when we added _Main to the Turret class.
There is no global coroutine manager or anything like that in the framework. If you want to use a coroutine, you instantiate it yourself, and then ‘tick’ it yourself as well.
After this, the graph structure is built on-demand, based on the instructions yield-ed by the user code. If the user indicates it wants to ‘Call’ a subroutine for instance, the runtime creates a new coroutine, sets it as the child of the current coroutine, and passes execution to it.
Which brings us to the interface that coroutine nodes need to implement: ICoroutine.
A basic node of the coroutine graph needs to be able to perform the following:
Be updated, of course, to do some actual work!
Indicate whether it is running or finished. That value is used, among other things, to determine when to return execution to a parent coroutine node.
Be reset, that is: restart whatever it is doing from the beginning.
Be disposed. This is crucial so that nodes can make sure they clean up after themselves in a predictable fashion. It also has the nice advantage that we can easily build a node pooling system once we know for a fact that disposed nodes are no longer used.
The Coroutine Node
The Coroutine node is the main workhorse of the framework. It is the node where user code is executed. The coroutine node is the one that understands the ‘Instructions’ I mentioned earlier. It can be represented like this:
And in practice, it stores the following data:
The coroutine needs to know the original IEnumerable so it can restart enumeration from the beginning when reset. Of course it stores the IEnumerator to keep track of where it is in the coroutine (for all intents and purposes, the IEnumerator is the auto-generated state machine). After that, it has two extra members: a state variable and a subroutine.
The subroutine is null until a control-flow instruction is yield-ed and tells the coroutine node how to create the child node. In the case of a CallInstruction as seen earlier, the Coroutine.Update() method sets a flag indicating that instead of iterating its iterator (the user code), it should instead create and then execute a child node. Once that child node completes, it can reset the flag and continue iterating its iterator (the user code). In most cases, the subroutine it creates is itself an iterator-based Coroutine, but in other cases, such as with ExecuteWhile(...), it is a node of a different type.
The While Node
The While