Coroutines: Directing the Action
You can pause a computation and resume it at will. Now point that power at the oldest dream in game programming: enemy patrols and cutscenes written as straight-line stories, played one slice per frame, while the game never stops breathing.
You will be able to…
- Write game behaviour as straight-line scripts that call
wait () - Build — and explain — a tick scheduler:
job,start,resume - Run several scripts "in parallel", one slice each per tick
- State the two laws: cooperate (scripts must wait) and one-shot (keep the newest job)
The dream, and why game loops crush it
Every game has code like this in its soul:
GUARD PATROL: step right. step right. step right. one long stride back. repeat. CUTSCENE: camel walks in … dramatic pause … caption fades up.
Written as prose, trivial. Written inside a game loop, it shatters —
because the loop owns time. Your step function gets one tick,
must return now, and is called again a frame later with no memory
beyond what you squirrelled away. So the guard's three-line story gets
chopped into "where was I?" bookkeeping: a variant of phases, a counter,
a match per beat. You built exactly these state machines in
Phase 2. They work. And for a boss with a five-act personality, they are
misery to write and worse to reread.
What we want is impossible-sounding: the script keeps its shape, and
when it says wait () it freezes mid-story until the next
tick. But freezing a computation mid-story is precisely what lesson
4.1 taught your code to do. This isn't a metaphor. The whole scheduler is
about a dozen lines.
A one-word vocabulary: Wait
Here is the entire coroutine kit, followed by a script to drive through it. Run it and match each printed line to the code:
The output interleaves the two worlds — story lines printed by the script, status lines printed by the driver:
Piece by piece:
Waitis the one question a script ever asks: "may I have the rest of this tick off?" Its answer type isunit— nothing comes back except later.jobis a script's public face: finished (Done) or frozen mid-story (Paused k— the save file from 4.1, now stored in a variant instead of resumed on the spot).startruns the script up to its firstwait(or, if it never waits, clean to the end).resumeloads the save:Effect.Deep.continue k ().
But hold on — resume is one line? If the script
waits again, who catches it? This is 4.1's deep-handler fact doing real
work: the handler travels with the continuation. When the resumed
script performs Wait again, that same effect
branch inside start fires again, wraps the new pause as
Paused k2 — and that value arrives as the return value
of continue k (). So resume always hands
you the next job, with zero extra machinery. A dozen lines,
full coroutines.
Break it on purpose: the one-shot law
Lesson 4.1 warned that continuations are one-shot tickets. Let's see the law enforced — run this:
The first resume j plays the ending ("encore?") and
returns Done — but we threw that away and fed the
stale j in again. The engine answers with the
Continuation_already_resumed exception: a save file this
magical can only be loaded once.
The kit is in the starter — keep it as given. Write
ticks_to_finish : (unit -> unit) -> int:
start the script, resume until it's
Done, and return how many resume calls it took.
A script that never waits finishes during start, so its
answer is 0.
start script gives you the first
job, with zero resumes spent so far.let rec loop count job = match job with Done -> count |
Paused _ -> loop (count + 1) (resume job) — then
loop 0 (start script).Scripting a believable guard
Now write behaviour the way the dream promised. A guard pacing his
post: three steps out, one per tick, then one long stride home. The script
owns an int ref — the piece of world it touches — and reads
exactly like his orders. No phases, no counters, no "where was I?".
Keep the kit. Write patrol : int ref -> unit ->
unit, a script that, three times over, steps right
(x := !x + 1) and then wait ()s — and
finally strides all the way back with x := !x - 3. The
tests start it and step it tick by tick, checking the position after
each slice: 1, 2, 3, then home at 0.
for _ = 1 to 3 do … done loop with two
statements inside: bump the ref, then wait (). After the
loop, subtract 3.x right after start, and the first
step must already be taken by then.Directing the whole cast
A real scene has many actors mid-story at once. Because a paused script
is just a value, "many actors" is just several jobs — and the
director resumes each one once per tick, round-robin. This is genuinely
how effect-based schedulers work; OCaml's Eio library is this idea with a
queue and better manners. One convenient detail of our kit: resume
Done = Done, so you can resume everyone without checking pulses
first.
Keep the kit. Write run_both : job -> job -> int.
Round after round, resume every job that isn't Done once
(resuming a Done job is a harmless no-op, so "resume
both" is fine). Stop when both are Done and return the
number of rounds. Two already-finished jobs take 0
rounds; otherwise the slowest script sets the total.
resume a and resume
b. Since resume Done is a no-op, no pulse-checking
needed.match a, b with | Done,
Done -> rounds | _ -> … — recurse with both new jobs and
rounds + 1.Lights, camera, cutscene
Grand finale: the scheduler meets the canvas. The animation callback below does two jobs — every frame it draws the stage, and every 30th frame it resumes the scene one beat. The cutscene itself is straight-line story: stride, stride, dramatic pause, caption. Count the state machines: zero. (This sample needs the in-page engine's effect support, which this site has; on localhost, the same code drops straight into your canvas-starter template from 1.3.)
Tweak it: lengthen the dramatic pause, add a second caption, make the camel moonwalk back out. Each change is one line of story — not a new state in a machine. That's the trade you made this lesson, and it only cost one effect.
Checkpoint quiz
Loot collected
- One effect (
Wait) plus a dozen lines gives coroutines:job=Done | Paused k,startruns to the first wait,resumeplays the next slice. - Deep handlers re-arm:
continue k ()returns the nextjob, becausestart's own branch catches the nextWait. - Scripts are straight-line OCaml — loops, refs and helpers between waits — instead of hand-built state machines.
- Two laws: cooperate (a script that never waits freezes the world) and one-shot (keep only the newest job).
Coroutines interleave beautifully — on one core, taking turns. Next lesson we stop taking turns: OCaml 5's other superpower runs your code on real cores, simultaneously. Bring a terminal; the browser sits that one out.