Unity6 Game Development · Lesson 13

Coroutines, yields and explicit stopping

Use Unity’s coroutine scheduler without pretending it is a background thread.

Unity 6 Editor walkthrough

The Unity script needs the Editor and the setup listed below. The separate C# companion practice runs in SharpRune without UnityEngine.

A coroutine owns a suspended sequence

A coroutine describes work that can pause at explicit yield points while Unity continues its Player loop. Calling StartCoroutine does not create a background worker, and statements before the next yield still occupy the calling thread. This distinction matters for gameplay: waiting between flashes is a scheduling problem, whereas a long calculation performed without yielding still stalls the frame. Decide which component owns the sequence, when that ownership begins, and which lifecycle transition should abandon it before choosing a yield instruction or writing the loop body.

The lesson's CoroutineTicker chooses an enabled lifetime. OnEnable starts one Tick iterator, and OnDisable explicitly stops the stored Coroutine handle and clears it. Unity's automatic stopping rules differ: disabling only a MonoBehaviour does not itself stop its coroutines, while deactivating its GameObject stops the attached routines. The explicit policy makes those paths agree for this component. Re-enabling starts a fresh iterator rather than resuming the stopped iterator, so its local tick counter begins at one again instead of remembering a previous suspension.

Portable C# companion: three planned steps

The SharpRune companion runs ordinary synchronous C#. Plan produces three decimal values of 0.25, and ToArray fully enumerates them immediately. The resulting length is three, and Sum is exactly 0.75 for these decimal inputs; invariant formatting prints the second line as 0.75. Those values describe a plan, not elapsed time. There is no Unity Player loop, no WaitForSeconds object and no scheduled delay here. Running this companion quickly or slowly cannot validate frame scheduling, stopping behavior or the timing of a real component.

IEnumerable<decimal> Plan() { for (int step = 0; step < 3; step++) yield return 0.25m; }
var steps = Plan().ToArray(); Console.WriteLine(steps.Length); Console.WriteLine(steps.Sum().ToString("0.00", CultureInfo.InvariantCulture));

Unity Editor: CoroutineTicker owns its routine

Copy CoroutineTicker.cs into a Unity 6.0 project and attach it to an active GameObject. The excerpt below is engine code: each iteration yields before logging its tick. WaitForSeconds uses scaled time and resumes on an eligible later frame, so three quarter-second waits are not a promise of a precisely timed 0.750-second wall-clock run. Observe Tick 1 through Tick 3, then repeat while disabling the component before completion. The OnDisable method, rather than an assumption about the checkbox, explains why this particular sequence ends.

private void OnEnable() { routine = StartCoroutine(Tick()); }
    private void OnDisable()
    {
        if (routine != null) StopCoroutine(routine);
        routine = null;
    }

Stopping is a lifecycle decision

A common ownership bug is starting routines from several unrelated entry points and keeping only the newest handle. Stopping that handle leaves earlier sequences running. Another is expecting a stopped iterator's final gameplay work to execute automatically, even though it never reaches subsequent statements. Put essential state cleanup in the owning lifecycle policy and make it safe to repeat. Keep scaled and unscaled waiting explicit as well: pausing timeScale changes a scaled wait's progress, but does not turn coroutine scheduling into a thread sleep or guarantee an exact frame boundary.

Two independent scheduling experiments

First, in an independent Editor copy, disable only the component after the first tick and then re-enable it. Predict a fresh Tick 1 followed by later ticks because the supplied OnDisable stops the old handle. Temporarily remove that explicit stop in the copy and compare the result while keeping the GameObject active. Hint: test the enabled checkbox and SetActive separately; they change different engine state. Restore the original script afterward, and record which observation came from Unity's automatic rule and which came from the script's policy.

Second, create a separate timing experiment using WaitForSecondsRealtime instead of the scaled instruction, leaving the lifecycle policy unchanged. Compare a run with timeScale set to zero and a normal run, restoring the global setting when finished. Hint: measure and label wall time separately from scaled game time, and accept frame-based resumption rather than asserting exact timestamps. This exercise investigates the time source, not restart semantics. The portable decimal total remains 0.75 in both cases because its arithmetic never consulted either clock or the engine scheduler.

Review the owner, scheduler and time source

Before moving on, explain the sequence from ownership through suspension to cleanup without describing it as another thread. Identify which statements can execute before the first yield and which statements cannot execute after a stop. Keep a short record of the GameObject's active state, the component's enabled state and the chosen time source for every Editor observation. A successful console trace checks the arithmetic plan only. A successful scheduling experiment additionally needs the actual Unity component, its lifecycle transitions and an observed sequence of tick messages.

  • Name the component that owns the stored Coroutine handle.
  • Distinguish component disable from GameObject deactivation.
  • Explain why re-enabling begins a new Tick sequence.
  • Keep planned durations separate from measured wall time.
Runs in Unity 6 Editor

Unity Editor example

Set up in Unity 6

  1. Attach CoroutineTicker.cs to an active GameObject and enter Play mode.
  2. Observe three tick logs separated by scaled waits, without asserting exact wall-clock timestamps.
  3. Disable the component before its third tick; this script’s OnDisable explicitly stops the stored coroutine.
  4. Re-enable and observe a fresh sequence; compare deactivating the GameObject in a separate run.
  5. In a copy, compare WaitForSecondsRealtime or timeScale zero, restoring global timeScale after the experiment.

CoroutineTicker.cs

Download Unity script ↗
using System.Collections;
using UnityEngine;
public sealed class CoroutineTicker : MonoBehaviour
{
    private Coroutine routine;
    private void OnEnable() { routine = StartCoroutine(Tick()); }
    private void OnDisable()
    {
        if (routine != null) StopCoroutine(routine);
        routine = null;
    }
    private IEnumerator Tick()
    {
        for (int tick = 1; tick <= 3; tick++)
        {
            yield return new WaitForSeconds(0.25f);
            Debug.Log("Tick " + tick, this);
        }
        routine = null;
    }
}

What to observe in Unity

The enabled component emits Tick1 through Tick3 unless explicitly stopped. Disable/enable behavior comes from this script’s ownership policy, and waits follow Unity scheduling rather than the companion lab.

The console practice below isolates the C# logic. It does not run a Unity scene or emulate Unity engine callbacks.

C# companion practice

using System;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;

IEnumerable<decimal> Plan() { for (int step = 0; step < 3; step++) yield return 0.25m; }
var steps = Plan().ToArray(); Console.WriteLine(steps.Length); Console.WriteLine(steps.Sum().ToString("0.00", CultureInfo.InvariantCulture));
Open C# practice

C# practice output

3
0.75
Try this
The console plan is synchronous arithmetic; Unity alone advances the real coroutine.

Further reading