Skip to main content

πŸ“ Lesson 3.3: Coroutines and Async in Unity

How do you "wait 3 seconds, then open the door" or "fade the screen over 1 second" when Update can't sleep? The answer is a coroutine β€” a method that runs a bit, pauses, and resumes across frames. This lesson covers coroutines in depth, then compares them to C#'s async/await.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain why blocking or Thread.Sleep is wrong in Unity's single-threaded loop
  • Write a coroutine with IEnumerator and yield return, and start it with StartCoroutine
  • Use the key yield instructions (null, WaitForSeconds, WaitUntil, …)
  • Stop coroutines and understand how object enable/destroy affects them
  • Compare coroutines to async/await and Unity 6's Awaitable
  • Choose the right tool for delays, sequences, and background work

Estimated Time: 75 minutes

Project: A door that opens after a delay, and a screen-fade sequence β€” both built as coroutines.

In This Lesson

Why You Can't Just Sleep

Your instinct from console C# might be: "wait 3 seconds" = Thread.Sleep(3000). In Unity that freezes the entire game. Remember from Lesson 1.2 that Unity runs your code on one main thread, calling Update each frame. Blocking that thread means no rendering, no input, no anything β€” a frozen window for 3 seconds.

void Update()
{
    if (triggered)
    {
        Thread.Sleep(3000);   // ❌ freezes the whole game for 3 seconds
        OpenDoor();
    }
}

You need a way to say "pause this task for 3 seconds, but keep the game running." That's exactly what a coroutine does: it lets a method give control back to Unity, then pick up where it left off later.

πŸ“– Definition

A coroutine is a method that can pause its own execution (yield) and resume on a later frame, while the rest of the game keeps running. It runs on the main thread β€” it's not multithreading β€” it just spreads its work across many frames.

πŸ’‘ Connection to Intermediate C#: a coroutine is literally a C# iterator β€” the IEnumerator/yield return feature you saw for lazy sequences. Unity repurposes it: each yield hands a value back to the engine that says "resume me when this condition is met."

Coroutine Basics

Three pieces: the method returns IEnumerator, it uses yield return to pause, and you launch it with StartCoroutine.

using System.Collections;   // IEnumerator lives here
using UnityEngine;

public class Door : MonoBehaviour
{
    void Start()
    {
        StartCoroutine(OpenAfterDelay());   // launch the coroutine
    }

    private IEnumerator OpenAfterDelay()
    {
        Debug.Log("Waiting...");
        yield return new WaitForSeconds(3f);   // pause HERE for 3 seconds, game keeps running
        Debug.Log("Opening!");                 // resumes here 3 seconds later
        Open();
    }

    private void Open() => transform.Translate(Vector3.up * 3f);
}
graph LR A["StartCoroutine"] --> B["run until first yield"] B --> C["yield: hand control
back to Unity"] C --> D["game keeps running
(other Updates, rendering)"] D --> E["condition met
(e.g. 3s passed)"] E --> F["resume after the yield"] F --> G["finish (or yield again)"] style C fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style F fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px

πŸ’‘ Reading a coroutine

yield return X; means "pause here; resume after X is satisfied." Everything before the yield runs now; everything after runs when it resumes. A coroutine can have many yields, running like a little script that unfolds over time β€” code you read top-to-bottom that executes across frames.

⚠️ Important: StartCoroutine is a MonoBehaviour method, and coroutines are tied to the MonoBehaviour that starts them. Start it on the component that "owns" the task. You can keep the returned Coroutine handle to stop it later.

The Yield Instructions

What you yield return decides when the coroutine resumes. The essential set:

YieldResumes…Use for
yield return null;Next frame (after all Updates)Doing a little work per frame (fades, gradual motion)
yield return new WaitForSeconds(t);After t scaled secondsTimed delays affected by Time.timeScale (pause-aware)
yield return new WaitForSecondsRealtime(t);After t real secondsDelays that ignore pause (menus while timeScale = 0)
yield return new WaitUntil(() => cond);When the condition becomes trueWaiting on state ("until loaded")
yield return new WaitForFixedUpdate();After the next physics stepCoordinating with physics
yield return anotherCoroutine;When that coroutine finishesSequencing coroutines

⚠️ Two classic yield gotchas

(1) WaitForSeconds obeys Time.timeScale: if you pause the game with Time.timeScale = 0, a WaitForSeconds never completes. Use WaitForSecondsRealtime for anything that must run while paused. (2) Don't allocate in a tight loop: yield return new WaitForSeconds(1f) inside a loop allocates a new object each iteration β€” cache it in a field (WaitForSeconds wait = new(1f);) and reuse it (Lesson 5.1).

A per-frame example β€” a smooth fade over one second using yield return null:

private IEnumerator FadeOut(CanvasGroup group, float duration)
{
    float elapsed = 0f;
    while (elapsed < duration)
    {
        elapsed += Time.deltaTime;                 // count up each frame
        group.alpha = 1f - (elapsed / duration);   // 1 β†’ 0
        yield return null;                          // wait one frame, then loop
    }
    group.alpha = 0f;   // ensure exact final value
}

Common Patterns

Coroutines shine wherever something unfolds over time. Three you'll reuse constantly:

Timed sequence

Do a series of steps with pauses between β€” a cutscene, a tutorial, a countdown:

private IEnumerator StartRound()
{
    ui.Show("Ready");
    yield return new WaitForSeconds(1f);
    ui.Show("Set");
    yield return new WaitForSeconds(1f);
    ui.Show("Go!");
    yield return new WaitForSeconds(0.5f);
    ui.Hide();
    BeginPlay();
}

Spawn waves

Repeat an action on an interval (with the cached-wait optimization):

[SerializeField] private Enemy enemyPrefab;
[SerializeField] private float interval = 2f;

private IEnumerator SpawnWaves()
{
    var wait = new WaitForSeconds(interval);   // cache β€” don't allocate per loop
    while (true)                               // runs until the object is disabled/destroyed
    {
        Instantiate(enemyPrefab, transform.position, Quaternion.identity);
        yield return wait;
    }
}

Wait for a condition

private IEnumerator WaitForLoad()
{
    yield return new WaitUntil(() => assetLoader.IsReady);
    Debug.Log("Loaded β€” continue.");
}

βœ… Coroutines pair beautifully with events (Lesson 3.2)

A common combo: raise an event to announce something happened, and start a coroutine to play out the timed reaction (flash, delay, then reset). Events say "what," coroutines say "over what span of time."

Stopping & Lifecycle Gotchas

Coroutines are tied to the MonoBehaviour that started them, which leads to important rules:

EventWhat happens to the coroutine
GameObject destroyedCoroutine stops immediately (and won't finish its remaining code).
GameObject/component disabled (SetActive(false) / enabled = false)Coroutine stops. Re-enabling does not resume it.
You call StopCoroutine / StopAllCoroutinesStops that coroutine (or all on this MonoBehaviour).
private Coroutine spawner;

void OnEnable()  => spawner = StartCoroutine(SpawnWaves());
void OnDisable() => StopCoroutine(spawner);   // tidy stop; also happens automatically

// Stop by handle is precise; StopAllCoroutines() stops every coroutine on this component.

⚠️ Disabling stops coroutines β€” a frequent surprise

If a coroutine "mysteriously stops halfway," check whether its GameObject got deactivated. A coroutine on a disabled object is dead, not paused. If you need work to continue regardless, run it on a separate always-active manager object, or use async (next section) which isn't tied to enable state β€” with its own caveats.

πŸ’‘ Starting by method name: you can also StartCoroutine("SpawnWaves") with a string, which lets StopCoroutine("SpawnWaves") stop it by name β€” but the string form is slower and typo-prone. Prefer starting with the method call and stopping via the returned Coroutine handle.

Coroutines vs async/await

C# has its own way to write code that waits without blocking: async/await (Intermediate C#). It works in Unity too, and Unity 6 adds Awaitable to make it first-class. So when do you use which?

using UnityEngine;

public class Door : MonoBehaviour
{
    // Unity 6's Awaitable makes async play nicely with the frame loop.
    private async void Start()
    {
        Debug.Log("Waiting...");
        await Awaitable.WaitForSecondsAsync(3f);   // non-blocking, resumes on main thread
        Debug.Log("Opening!");
        transform.Translate(Vector3.up * 3f);
    }
}
Coroutinesasync / await
Tied to a MonoBehaviour's enable/lifetimeβœ… Yes (auto-stops)❌ No β€” keeps running after the object dies unless you cancel
Return a value❌ Awkwardβœ… Task<T> / Awaitable<T>
Real background-thread work (heavy CPU)❌ No (main thread only)βœ… Yes (with care returning to main thread)
Integrates with .NET APIs (web requests, file I/O)Clunkyβœ… Natural
Simplicity for timed game logicβœ… Very simpleMore moving parts

⚠️ The big async gotcha in Unity

An async method is not tied to the object's lifecycle. If the GameObject is destroyed while an await is pending, the continuation may still run and touch a destroyed object β€” throwing errors or worse. You must guard with a CancellationToken (Unity 6 offers destroyCancellationToken on MonoBehaviour) or check this != null after awaits. Coroutines don't have this problem because Unity stops them automatically. Also avoid async void except for event handlers β€” prefer async Awaitable/Task so errors surface.

βœ… Rule of thumb

Coroutines for game-flow timing that should live and die with the object β€” delays, sequences, spawners, fades. async/await for value-returning operations, real asynchronous I/O (loading, web, addressables), and heavy work you want off the main thread. When in doubt for gameplay, reach for a coroutine β€” it's simpler and self-cleaning.

Exercise & Quiz

πŸ‹οΈ Exercise: Delayed Door + Countdown

Objective: Use coroutines for a timed sequence and a per-frame effect.

Instructions:

  1. Create a Cube Door. Write a coroutine OpenAfterDelay that waits 3 seconds, then moves the door up β€” start it from Start.
  2. Add a countdown: before opening, log "3", "2", "1" one second apart using WaitForSeconds in a loop (cache the wait).
  3. Make the door move smoothly over 1 second using a while loop with yield return null and Time.deltaTime (interpolate the position) rather than teleporting.
  4. Bonus: add a StopCoroutine path β€” pressing Escape cancels the opening before it finishes.
  5. Reflect: what happens to the coroutine if you deactivate the Door mid-countdown? (It stops and won't resume.)

Starter Code:

using System.Collections;
using UnityEngine;

public class Door : MonoBehaviour
{
    [SerializeField] private float openHeight = 3f;
    [SerializeField] private float moveDuration = 1f;

    void Start() => StartCoroutine(OpenSequence());

    private IEnumerator OpenSequence()
    {
        // TODO: count down 3..2..1 (WaitForSeconds), then smoothly raise the door.
        yield return null;
    }
}
πŸ’‘ Hint

Countdown: for (int i = 3; i > 0; i--) { Debug.Log(i); yield return wait; } with a cached WaitForSeconds wait = new(1f);. Smooth move: capture Vector3 start = transform.position; and Vector3 end = start + Vector3.up * openHeight;, then loop elapsed += Time.deltaTime, set transform.position = Vector3.Lerp(start, end, elapsed / moveDuration), yield return null.

βœ… Solution
using System.Collections;
using UnityEngine;

public class Door : MonoBehaviour
{
    [SerializeField] private float openHeight = 3f;
    [SerializeField] private float moveDuration = 1f;

    void Start() => StartCoroutine(OpenSequence());

    private IEnumerator OpenSequence()
    {
        var oneSecond = new WaitForSeconds(1f);   // cached, no per-loop allocation

        for (int i = 3; i > 0; i--)
        {
            Debug.Log(i);
            yield return oneSecond;
        }
        Debug.Log("Opening!");

        Vector3 start = transform.position;
        Vector3 end = start + Vector3.up * openHeight;
        float elapsed = 0f;

        while (elapsed < moveDuration)
        {
            elapsed += Time.deltaTime;
            transform.position = Vector3.Lerp(start, end, elapsed / moveDuration);
            yield return null;                    // one frame; smooth over moveDuration
        }
        transform.position = end;                 // snap to exact final position
    }
}

The countdown uses timed waits; the smooth move uses per-frame yield return null with Lerp. Two of the most common coroutine shapes in one method β€” and it all stops automatically if the door is destroyed.

🎯 Quick Quiz

Question 1: Why is Thread.Sleep(3000) the wrong way to "wait 3 seconds" in Unity?

Question 2: A coroutine does yield return new WaitForSeconds(2f), but the game is paused with Time.timeScale = 0. What happens?

Question 3: Which is a genuine advantage of async/await over coroutines?

Summary

πŸŽ‰ Key Takeaways

  • Never block Unity's main thread (Thread.Sleep) β€” it freezes the whole game. Spread timed work across frames instead.
  • A coroutine is an IEnumerator method that yield returns to pause and resume; launch it with StartCoroutine. It runs on the main thread, not a separate one.
  • The yield you return sets the resume point: null (next frame), WaitForSeconds/WaitForSecondsRealtime, WaitUntil, WaitForFixedUpdate, or another coroutine.
  • Coroutines are tied to their MonoBehaviour: disabling or destroying the object stops them (and re-enabling doesn't resume). Cache WaitForSeconds to avoid per-loop allocations.
  • async/await (with Unity 6's Awaitable) suits value-returning ops, real async I/O, and background work β€” but is not auto-stopped by object lifecycle; guard with a cancellation token.
  • Default to coroutines for gameplay timing (delays, sequences, fades); reach for async when you need return values or true asynchrony.

πŸ“š Additional Resources

πŸš€ What's Next?

That completes Module 3 β€” Structuring Game Code: data assets, decoupled events, and time-based logic. Module 4 builds the systems that hold a whole game together. Lesson 4.1, Game State and Managers, covers singletons, scene management, and the central controllers that coordinate everything you've built.

πŸŽ‰ Module 3 complete!

You can now sequence gameplay over time without freezing a frame. Delays, cutscenes, fades, waves β€” the tempo of your game is now in your hands.