How I recreated Unity's coroutine system

Oliver Oliver • Published 2 years ago Updated 2 years ago


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.)

Tip

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 Behaviours 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”

What in tarnation? Great. We broke math.

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.

What in taxation without representation?

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>
}

What in-… okay this is good enough.

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:

Powerlines in Windows Terminal do be nice tho.

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 IEnumerators 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!

Beautiful. Just beautiful.

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!




1 legacy comment

Legacy comments are comments that were posted using a commenting system that I no longer use. This exists for posterity.

Matsuo Matsuo • 2 years ago

This was really interesting to learn, thank you so much!