π 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.Sleepis wrong in Unity's single-threaded loop - Write a coroutine with
IEnumeratorandyield return, and start it withStartCoroutine - Use the key yield instructions (
null,WaitForSeconds,WaitUntil, β¦) - Stop coroutines and understand how object enable/destroy affects them
- Compare coroutines to
async/awaitand Unity 6'sAwaitable - 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 β theIEnumerator/yield returnfeature you saw for lazy sequences. Unity repurposes it: eachyieldhands 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);
}
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:StartCoroutineis aMonoBehaviourmethod, and coroutines are tied to the MonoBehaviour that starts them. Start it on the component that "owns" the task. You can keep the returnedCoroutinehandle to stop it later.
The Yield Instructions
What you yield return decides when the coroutine resumes. The essential set:
| Yield | Resumes⦠| 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 seconds | Timed delays affected by Time.timeScale (pause-aware) |
yield return new WaitForSecondsRealtime(t); | After t real seconds | Delays that ignore pause (menus while timeScale = 0) |
yield return new WaitUntil(() => cond); | When the condition becomes true | Waiting on state ("until loaded") |
yield return new WaitForFixedUpdate(); | After the next physics step | Coordinating with physics |
yield return anotherCoroutine; | When that coroutine finishes | Sequencing 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:
| Event | What happens to the coroutine |
|---|---|
| GameObject destroyed | Coroutine 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 / StopAllCoroutines | Stops 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 alsoStartCoroutine("SpawnWaves")with a string, which letsStopCoroutine("SpawnWaves")stop it by name β but the string form is slower and typo-prone. Prefer starting with the method call and stopping via the returnedCoroutinehandle.
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);
}
}
| Coroutines | async / 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 simple | More 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:
- Create a Cube
Door. Write a coroutineOpenAfterDelaythat waits 3 seconds, then moves the door up β start it fromStart. - Add a countdown: before opening, log "3", "2", "1" one second apart using
WaitForSecondsin a loop (cache the wait). - Make the door move smoothly over 1 second using a
whileloop withyield return nullandTime.deltaTime(interpolate the position) rather than teleporting. - Bonus: add a
StopCoroutinepath β pressingEscapecancels the opening before it finishes. - 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
IEnumeratormethod thatyield returns to pause and resume; launch it withStartCoroutine. 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
WaitForSecondsto 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
- Manual β Coroutines
- Scripting Reference β StartCoroutine
- Scripting Reference β Awaitable (Unity 6)
- Manual β Asynchronous programming with Awaitable
π 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.