When working with Unity’s scripting system, coroutines are an essential feature for handling time-dependent tasks such as animations, delays, or timed events. Most developers know about yield return new WaitForSeconds(), but there are many other powerful uses of yield return that offer greater flexibility and cleaner code structure.
In this advanced Unity tutorial, we’ll explore how to effectively use yield return beyond basic waits, enabling you to write more modular, maintainable coroutine code.
Understanding yield return in Coroutines
A coroutine is a special method that allows you to pause execution and return control to Unity’s game loop. The function resumes from where it left off after the specified condition is met.
yield return controls this flow. Here’s the basic pattern:
IEnumerator ExampleCoroutine()
{
Debug.Log("Starting...");
yield return new WaitForSeconds(2f); // Wait 2 seconds
Debug.Log("2 seconds have passed!");
}
Beyond WaitForSeconds: Advanced Patterns
1. WaitUntil and WaitWhile
Wait for specific conditions rather than fixed time periods:
IEnumerator WaitForPlayerJump()
{
while (!player.IsGrounded())
{
yield return null; // Wait one frame
}
Debug.Log("Player jumped!");
}
// Or using WaitUntil (Unity 2019+)
IEnumerator WaitForPlayerJumpModern()
{
yield return new WaitUntil(() => player.IsGrounded());
Debug.Log("Player jumped!");
}
Use cases:
- Waiting for UI elements to appear
- Pausing until a network request completes
- Resuming when an animation finishes
2. Custom Yield Instructions
Create reusable wait conditions:
public class WaitForAnimation : CustomYieldInstruction
{
private Animator animator;
private string stateName;
public WaitForAnimation(Animator animator, string stateName)
{
this.animator = animator;
this.stateName = stateName;
}
public override bool keepWaiting
{
get
{
return !animator.IsInTransition(0) &&
animator.GetCurrentAnimatorStateInfo(0).IsName(stateName);
}
}
}
// Usage:
IEnumerator PlayAndDoSomething()
{
animator.SetTrigger("Attack");
yield return new WaitForAnimation(animator, "AttackComplete");
Debug.Log("Animation finished!");
}
3. Chaining Coroutines
Build complex sequences from simple, reusable parts:
IEnumerator CutsceneSequence()
{
// Step 1: Fade out current scene
yield return StartCoroutine(FadeOut(1f));
// Step 2: Load new scene asynchronously
yield return StartCoroutine(LoadSceneAsync("Level2"));
// Step 3: Fade in the new scene
yield return StartCoroutine(FadeIn(1.5f));
// Step 4: Play intro animation
yield return StartCoroutine(PlayIntroAnimation());
}
IEnumerator FadeOut(float duration)
{
float elapsed = 0f;
while (elapsed < duration)
{
elapsed += Time.deltaTime;
uiManager.SetAlpha(1f - (elapsed / duration));
yield return null;
}
}
4. Coroutine with Data Passing
While coroutines can’t directly return values, use callbacks or class members:
private string loadedSceneName;
IEnumerator LoadAndReport()
{
AsyncOperation operation = SceneManager.LoadSceneAsync("Menu");
while (!operation.isDone)
{
float progress = operation.progress;
Debug.Log($"Loading... {progress * 100}%");
yield return null;
}
loadedSceneName = SceneManager.GetActiveScene().name;
}
// Access result after coroutine completes:
void OnCoroutineComplete()
{
Debug.Log($"Loaded scene: {loadedSceneName}");
}
5. Parallel Coroutines with WaitAll
Wait for multiple coroutines to complete (Unity 2021+):
IEnumerator WaitForMultipleTasks()
{
var task1 = StartCoroutine(DownloadData());
var task2 = StartCoroutine(LoadAssets());
// Wait for both to finish
yield return new WaitAll(task1, task2);
Debug.Log("Both tasks complete! Starting game...");
}
6. Timed Coroutine with Early Exit
Combine time limits with conditions:
IEnumerator WaitForInputWithTimeout(float timeout)
{
float elapsed = 0f;
while (elapsed < timeout && !player.HasPressedStart())
{
elapsed += Time.deltaTime;
yield return null;
// Update UI timer display
uiManager.UpdateTimer(timeout - elapsed);
}
if (player.HasPressedStart())
{
Debug.Log("Player started the game!");
}
else
{
Debug.Log("Timeout! Starting anyway...");
}
}
Modular Coroutine Design Patterns
State Machine with Coroutines
public class EnemyAI : MonoBehaviour
{
private enum State { Patrol, Chase, Attack }
private State currentState;
IEnumerator RunStateMachine()
{
while (true)
{
switch (currentState)
{
case State.Patrol:
yield return StartCoroutine(Patrol());
break;
case State.Chase:
yield return StartCoroutine(ChasePlayer());
break;
case State.Attack:
yield return StartCoroutine(Attack());
break;
}
}
}
}
Coroutine Pool for Object Spawning
public class ObjectPool : MonoBehaviour
{
public GameObject prefab;
public int poolSize = 10;
private Queue<GameObject> pool;
void Start()
{
pool = new Queue<GameObject>();
for (int i = 0; i < poolSize; i++)
{
var obj = Instantiate(prefab);
obj.SetActive(false);
pool.Enqueue(obj);
}
StartCoroutine(PeriodicSpawn());
}
IEnumerator PeriodicSpawn()
{
while (true)
{
if (pool.Count > 0)
{
var obj = pool.Dequeue();
obj.SetActive(true);
// Use object...
yield return new WaitForSeconds(5f);
obj.SetActive(false);
pool.Enqueue(obj);
}
else
{
yield return null;
}
}
}
}
Common Pitfalls and Solutions
1. Forgetting to Stop Coroutines
Always cancel coroutines when objects are destroyed:
private IEnumerator currentCoroutine;
void Start()
{
currentCoroutine = DoSomething();
StartCoroutine(currentCoroutine);
}
void OnDestroy()
{
if (currentCoroutine != null)
StopCoroutine(currentCoroutine);
}
2. Modifying Collections While Iterating
Never modify a list inside a coroutine that’s iterating over it:
// BAD - will throw exception
IEnumerator DestroyAllEnemies()
{
foreach (var enemy in enemies)
{
Destroy(enemy.gameObject); // Modifies the collection!
}
}
// GOOD - use a copy or reverse iteration
IEnumerator DestroyAllEnemiesSafe()
{
for (int i = enemies.Count - 1; i >= 0; i--)
{
Destroy(enemies[i].gameObject);
}
}
3. Race Conditions with Shared State
Use locks or coroutine sequencing to prevent conflicts:
private bool isProcessing = false;
IEnumerator ProcessData()
{
if (isProcessing) yield break; // Skip if already processing
isProcessing = true;
// Do work...
yield return new WaitForSeconds(2f);
isProcessing = false;
}
Best Practices Summary
- Use
WaitUntil/WaitWhilefor condition-based waits instead of polling loops - Create custom YieldInstructions for reusable wait conditions (animations, network calls)
- Chain coroutines to build complex sequences from simple parts
- Always stop coroutines in
OnDestroy()or when objects are disabled - Avoid modifying collections inside iterating coroutines
- Use callbacks instead of trying to return values from coroutines
Related Tutorials
- Understanding Unity Coroutines – Beginner-friendly coroutine introduction
- How Does StartCoroutine Really Work? – Deep dive into coroutine mechanics
- A Useful Countdown Timer Class in C# – Reusable timer utility
Mastering yield return patterns will make your Unity code cleaner and more maintainable. Check out our other Unity tutorials for more tips!