Early last year, I wrote a blog post which explained how Unity's coroutines work. In my conclusion, I expressed that I underestimated the level of wizardry they involved, so much so that it went far beyond the scope of the article. I figured it was time to perhaps elaborate on that, and show you a glimpse of Unity's game loop and how the coroutine system works in the engine (at least, my interpretation of it.)
This article builds on the knowledge from another post. If you haven't already, I strongly recommend you read my article on how Unity's coroutines work before continuing.
To start off, we're going to need to make a game engine.
Now - before you run away from how intimidating that sounds, let me just clarify that we won't be doing any 2D or 3D rendering whatsoever. This is going to purely be a console application, where coroutines have the ability to write to the console. Nothing more.
Behaviours
First we will create an abstract Behaviour
class which will serve as a pseudo-equivalent of MonoBehaviour
. We'll define virtual Start
and Update
methods so that inheritors can override them if they choose to. Because who needs a messaging system when you have inheritance?
public abstract class Behaviour
{
public virtual void Start()
{
}
public virtual void Update()
{
}
}
Now we can create our own behaviour which will - for now - simply set the console's title to the current frame-rate.
public class MySimpleBehaviour : Behaviour
{
public override void Update()
{
Console.Title = $"Running at {1 / <mark>Time.DeltaTime</mark>:F0} fps";
}
}
This will obviously throw a compile error, we haven't defined a Time
class with a DeltaTime
property. So let's go ahead do that.
public static class Time
{
public static float DeltaTime { get; internal set; }
}
Perfect. We have our behaviour. So how do we go about having it actually work? Well, it's time to create a barebones game engine!
The game loop
In our Program
class, let's create a list of Behaviour
s that the engine needs to be aware of, and add our simple behaviour to that list.
internal static class Program
{
<mark>private static readonly List<Behaviour> BehaviourList = new();</mark>
private static void Main()
{
<mark>BehaviourList.Add(new MySimpleBehaviour());</mark>
}
}
Now we can create an incredibly simple game loop so that we can call Update
on every behaviour each frame and Start
before the loop begins. Start off by just having an infinite loop.
internal static class Program
{
private static readonly List<Behaviour> BehaviourList = new();
private static void Main()
{
BehaviourList.Add(new MySimpleBehaviour());
foreach (Behaviour behaviour in BehaviourList)
{
<mark>behaviour.Start();</mark>
}
while (true)
{
// game loop
foreach (Behaviour behaviour in BehaviourList)
{
<mark>behaviour.Update();</mark>
}
}
}
}
Run the application, and you'll see that our behaviours are initialised and updating as expected! However, first thing to note at this point is that the title of the console reads “Running at ∞ fps”
This is because our behaviour is calculating 1 / Time.DeltaTime
. Since we never updated DeltaTime
, it's defaulted to 0 and so it evaluates to 1 / 0
giving us float.PositiveInfinity
. At least it didn't error out, right?
So to fix this, let's update Time.DeltaTime
each frame so that it represents the number of seconds since the last frame. We can achieve this by using some value to represent the current timestamp, and some value to represent the last frame's timestamp. The easiest way to do this is to use DateTime.Ticks
, and then we can convert that to seconds using a combination of TimeSpan.FromTicks
and finally TimeSpan.TotalSeconds
to store it back in our own Time.DeltaTime
.
<mark>long lastFrameTime = DateTime.Now.Ticks;</mark>
while (true)
{
// game loop
<mark>long frameTime = DateTime.Now.Ticks;</mark>
<mark>long deltaTime = frameTime - lastFrameTime;</mark>
<mark>lastFrameTime = frameTime;</mark>
<mark>Time.DeltaTime = (float) TimeSpan.FromTicks(deltaTime).TotalSeconds;</mark>
foreach (Behaviour behaviour in BehaviourList)
{
behaviour.Update();
}
}
Run the application now, and you'll see we now have an actual framerate - although it will be absurdly high.
This is because our game loop does not do anything that computationally expensive. We have a single behaviour which sets the title (which can happen very quickly), but aside from that the CPU is just looping as fast as it possibly can. Although it would be amazing to have a game run at this speed, it does put an enormous amount of strain on the hardware. Sooo let's quickly go ahead and add a Thread.Sleep
call for 10ms to “fake” a 60fps game loop.
long lastFrameTime = DateTime.Now.Ticks;
while (true)
{
// game loop
long frameTime = DateTime.Now.Ticks;
long deltaTime = frameTime - lastFrameTime;
lastFrameTime = frameTime;
Time.DeltaTime = (float) TimeSpan.FromTicks(deltaTime).TotalSeconds;
foreach (Behaviour behaviour in BehaviourList)
{
behaviour.Update();
}
<mark>Thread.Sleep(10);</mark>
}
Excellent. We have a game loop, and our behaviour is working nicely. Now it's time for the hard part.
Yes. This was the easy part.
Managing coroutines
In Unity, every coroutine is handled by the game object which started them. We have no concept of game objects in our engine, so instead we'll just have each behaviour handle them.
Let's define a StartCoroutine
method in our base Behaviour
class which accepts an IEnumerator
parameter (this parameter will generally be the result of a coroutine method call.)
<mark>using System.Collections;</mark>
public abstract class Behaviour
{
public virtual void Start()
{
}
public virtual void Update()
{
}
<mark>protected void StartCoroutine(IEnumerator coroutine)</mark>
<mark>{</mark>
<mark>}</mark>
}
Now here comes the tricky part. We need to keep track of coroutines that are currently active. But here's the catch! Coroutines can be nested. A coroutine can - itself - yield another coroutine, which could yield another coroutine, which could yield a WaitForSeconds
. To show what I mean, consider this example in Unity:
private void Start()
{
StartCoroutine(Foo());
StartCoroutine(Bar());
}
private IEnumerator Foo()
{
yield return Foo2();
Debug.Log("Foo finished!");
}
private IEnumerator Foo2()
{
for (int i = 0; i < 5; i++)
{
Debug.Log(i);
yield return new WaitForSeconds(1);
}
Debug.Log("Foo2 finished!");
}
private IEnumerator Bar()
{
yield return new WaitForSeconds(1);
Debug.Log("Bar finished!");
}
Both Foo
and Bar
run together, but Foo
yields Foo2
before it halts. And Foo2
then yields a WaitForSeconds
in a loop, before it finally halts. If - in our game loop - we only called MoveNext
on the started coroutine, it would not handle nested coroutines very well.
For the sake of science, let's demonstrate this. Let's first convert this behaviour to one that works in our console “engine”:
using System.Collections;
public class MySimpleBehaviour : Behaviour
{
public override void Update()
{
Console.Title = $"Running at {1 / Time.DeltaTime:F0} fps";
}
public override void Start()
{
StartCoroutine(Foo());
StartCoroutine(Bar());
}
private IEnumerator Foo()
{
yield return Foo2();
<mark>Console.WriteLine("Foo finished!");</mark>
}
private IEnumerator Foo2()
{
for (int i = 0; i < 5; i++)
{
<mark>Console.WriteLine(i);</mark>
yield return new WaitForSeconds(1);
}
<mark>Console.WriteLine("Foo2 finished!");</mark>
}
private IEnumerator Bar()
{
yield return new WaitForSeconds(1);
<mark>Console.WriteLine("Bar finished!");</mark>
}
}
We also need to define our own WaitForSeconds
class. I won't go into detail about how this works, you can find out on my previous post about this topic (seriously, do check it out if you haven't already).
using System.Collections;
internal class WaitForSeconds : IEnumerator
{
private readonly DateTime _endTime;
public WaitForSeconds(float seconds)
{
_endTime = DateTime.Now + TimeSpan.FromSeconds(seconds);
}
public object Current => null;
public bool MoveNext() => DateTime.Now < _endTime;
public void Reset() { } // not important
}
Okay, to be fair, this is actually an equivalent of Unity's WaitForSecondsRealtime
. However, I'm not about to implement Time.TimeScale
to accommodate a real equivalent of WaitForSeconds
, so this will do.
Now inside our Behaviour
class, we can keep track of all the active coroutines in a list. This'll also give StartCoroutine
something to actually do. Let's also create an UpdateCoroutines
method which will be responsible for calling MoveNext
for each coroutine every frame. When it finally returns false we can remove it from the list.
public abstract class Behaviour
{
<mark>private readonly List<IEnumerator> _activeCoroutines = new();</mark>
public virtual void Start()
{
}
public virtual void Update()
{
}
protected void StartCoroutine(IEnumerator coroutine)
{
<mark>_activeCoroutines.Add(coroutine);</mark>
}
<mark>internal void UpdateCoroutines()</mark>
<mark>{</mark>
<mark> for (int index = _activeCoroutines.Count - 1; index >= 0; index--)</mark>
<mark> {</mark>
<mark> IEnumerator coroutine = _activeCoroutines[index];</mark>
<mark> if (!coroutine.MoveNext())</mark>
<mark> _activeCoroutines.RemoveAt(index);</mark>
<mark> }</mark>
<mark>}</mark>
}
Finally, we call UpdateCoroutines
for each behaviour in the game loop.
long lastFrameTime = DateTime.Now.Ticks;
while (true)
{
// game loop
long frameTime = DateTime.Now.Ticks;
long deltaTime = frameTime - lastFrameTime;
lastFrameTime = frameTime;
Time.DeltaTime = (float) TimeSpan.FromTicks(deltaTime).TotalSeconds;
foreach (Behaviour behaviour in BehaviourList)
{
behaviour.Update();
<mark>behaviour.UpdateCoroutines();</mark>
}
Thread.Sleep(10);
}
Now run the application, and you'll see what I'm talking about regarding the nested yields:
Foo
and Bar
were both started and even both finished. But you'll notice they didn't wait. There was no 1 second pause before these results were outputted. And Foo2
didn't even start! Why? Well, because we're only calling MoveNext
on the IEnumerator
returned by both the Foo
and Bar
methods. But these methods also yield IEnumerator
s themselves, and we need a way to call MoveNext
on those too.
The way to solve this issue is by replicating what a computer would do when you call methods from other methods. The answer is: a call stack!
Instead of explaining it step by step, I'll just show you the complete code and then break it down.
using System.Collections;
public abstract class Behaviour
{
private readonly List<Stack<IEnumerator>> _activeCoroutines = new();
public virtual void Start()
{
}
public virtual void Update()
{
}
protected void StartCoroutine(IEnumerator coroutine)
{
_activeCoroutines.Add(new Stack<IEnumerator>(new[] {coroutine}));
}
internal void UpdateCoroutines()
{
for (int index = 0; index < _activeCoroutines.Count; index++)
{
Stack<IEnumerator> instructions = _activeCoroutines[index];
if (instructions.Count == 0)
{
_activeCoroutines.RemoveAt(index);
index--; // avoid skipping the next element
continue;
}
IEnumerator instruction = instructions.Peek();
if (!instruction.MoveNext())
{
instructions.Pop();
continue;
}
if (instruction.Current is IEnumerator next && instruction != next)
instructions.Push(next);
}
}
}
Okay. So what the hell is going on here?
_activeCoroutines
has been changed to a List<Stack<IEnumerator>>
. In StartCoroutine
, a new stack is created with the specified coroutine as the initial element.
Putting the initial if
statement to the side for a moment, the first thing we do is Peek
at the top instruction. Of course, the first instruction would be the coroutine itself. We call MoveNext
on it, and if it returns false (i.e. if the instruction is complete), we pop that instruction from the call stack. Otherwise, we check if its Current
value is a nested IEnumerator
, and if so Push
it to the call stack so that MoveNext
will be then called on that the next time.
Finally, after all instructions are popped, the initial if
statement evaluates to true. The call stack is empty, the coroutine is complete - and so it's now time to remove the coroutine from the list. We also decrement the index so that we don't skip the next stack in the list.
Run the application, and you will see that the Bar
coroutine waits 1 second before halting, Foo
is able to call Foo2
and the delayed counting works too!
That's pretty much all of it. I'm actually pretty sure I'll use this in my own game engine at some point, and I'm sure you could find a use for it too.
I also hope you now understand why I say the technicalities of this went far beyond the scope of the previous article. This system is not an easy one to get right. Hopefully you, too, can now truly appreciate how genius Unity's coroutines are.
If you'd like to download all of the code in full, I created a gist which has the complete project source available. Feel free to check it out.
Footnote
My initial implementation failed to handle nested IEnumerator
values well, and things were happening out of order. So special thanks to Zenvin for helping me to fix the UpdateCoroutines
loop!
This was really interesting to learn, thank you so much!