Generator coroutines are the textbook fix for callback hell in game sequencing. Write a boss script or a cutscene as a straight-line function* that yields to wait, instead of a pyramid of timers and callbacks. I built exactly that for my engine - CoroutineManager, 122 lines, 12 tests, three yield kinds. Then, when it came time to actually script the bosses in the shipped game, I used a finite state machine instead and left the coroutine system unused on purpose.
This post is about why. Two reasons that most TypeScript game coroutines tutorials never mention, because they don't matter until you ship a real-time, save-capable game.
The elegant case for TypeScript game coroutines
The pitch for coroutines is real and fair. Consider scripting a boss that pauses, fires, waits two seconds, then fires again. With timers and callbacks you get something like this:
// pseudocode
startPhase(() => {
fire();
setTimeout(() => {
fire();
setTimeout(() => {
startNextPhase();
}, 1000);
}, 2000);
});
With a generator coroutine, the same logic reads as a straight sequence:
// pseudocode
function* bossScript() {
fire();
yield 2.0; // wait 2 seconds
fire();
yield 1.0; // wait 1 second
startNextPhase();
}
The generator version is obviously better to read and modify. The sequencing matches the mental model of "first this, then that." It's the same pattern Unity uses - Unity coroutines are C# iterator methods (IEnumerator + yield return) started via StartCoroutine and stopped via StopCoroutine, with built-in yield instructions such as WaitForSeconds, WaitForEndOfFrame, WaitUntil, and WaitWhile. It's also why redux-saga, a widely-used Redux middleware, models side-effect orchestration with generator functions: a saga is a generator that yields plain JavaScript objects ("effects") describing what should happen, and the middleware interprets each effect and resumes the generator - a real-world precedent for generators as a sequencing model outside of games.
The readability argument won me over. So I built the scheduler.
What I actually built
The CoroutineManager lives in packages/core/src/coroutine.ts and clocks in at 122 lines. It tracks active coroutines in a list, ticks them on every update(dt) call, and handles three yield kinds: number (wait N seconds), undefined (wait one frame), and Promise (wait for async resolution).
The heart of the scheduler is the advance method. Here it is verbatim:
// packages/core/src/coroutine.ts
private advance(state: CoroutineState): void {
const result = state.gen.next();
if (result.done) {
state.done = true;
return;
}
const value = result.value;
if (typeof value === 'number') {
state.waitTime = value;
} else if (value instanceof Promise) {
state.waitingForPromise = true;
value.then(
() => {
state.waitingForPromise = false;
},
() => {
// On rejection, stop the coroutine
state.done = true;
},
);
}
// undefined → wait 1 frame (will advance next update)
}
The system has 12 tests. The only callers are those tests - nothing in the game or the engine's other packages uses CoroutineManager. Here is one of those test callsites, verbatim:
// packages/core/tests/coroutine.test.ts
mgr.start(
(function* () {
yield 0.5;
reached = true;
})(),
);
The ergonomics are clean. The tests pass. And then I chose not to wire it into the game.
Reason 1: allocation on the hot path
Look at advance again. The first line is const result = state.gen.next().
By the ECMAScript specification, every generator .next() returns a freshly created {value, done} object. ECMA-262 §7.4.12 (CreateIterResultObject) does OrdinaryObjectCreate(%Object.prototype%) then sets the value and done properties and returns that new object. On top of that, invoking the generator function in the first place allocates the generator object itself.
On Hermes - the JavaScript engine React Native uses - this result object is allocated on every .next() with no JIT to elide it, so a manually-driven generator loop produces steady per-step GC pressure. On V8, TurboFan's escape analysis can sometimes eliminate the iterResult allocation for plain for-of loops, but not for hand-driven .next() coroutine loops like this one. So on both engines you get a stream of small, short-lived objects accumulating across every frame.
This is not a hard per-frame performance cliff. Minor GC of young objects is cheap. The point is that it is non-zero allocation on a path engineered for zero allocation. The engine's frame loop is designed around zero per-frame heap allocation - typed arrays for component data, pooled event objects, pre-allocated collision structures. Running a coroutine scheduler in that loop introduces steady GC pressure from a source that compounds with every additional concurrent sequence.
No measured profiling number exists for this path, because I never wired the coroutines into the loop. The allocation argument is a design reason grounded in how generators work - not a benchmark.
Reason 2: you can't serialize a paused generator
The harder dealbreaker is serialization.
A paused JavaScript generator cannot be serialized. Its resume state lives in spec-internal slots - [[GeneratorState]] and [[GeneratorContext]], the latter holding the execution context with the suspended instruction position and live scope. These are not exposed to user code and have no defined serialization form. Call structuredClone on a generator and you get a DataCloneError on the closure. Call JSON.stringify and you get {}.
For a game engine that needs to support save states, replays, or deterministic tests, this matters. The sequence's progress must be plain, restorable data - something you can write to disk and reload. A generator's "where am I" is a black box.
The FSM solves this directly. The boss's state is a string field on the Boss component - b.phase. On every update, the system reads that string and drives behavior from it. Here is the serialization tell, verbatim from the real boss FSM:
// Pan-Tvardowski/src/systems/boss/fsm.ts
runtime.context.deps = deps;
const currentName = runtime.fsm.current?.name;
if (currentName !== b.phase) runtime.fsm.start(b.phase);
b.phase is the entire state. To save the game, serialize b.phase. To load, set b.phase and call fsm.start(b.phase) - the FSM reconstructs from that string. A paused generator has no equivalent. You would have to re-implement explicit progress tracking anyway, at which point you have reinvented the FSM.
What won and why
The finite state machine won on the two axes that matter for a real-time, save-capable game.
Its authoritative state is a string on a component - zero per-frame allocation, trivially serializable. The FSM primitive itself is 76 lines in packages/ecs/src/fsm.ts. The PT boss system uses it today: phase edges drive transitions, cooldown-driven fire callbacks handle the in-phase cadence. There is no function* anywhere in the boss code.
A zero-allocation data-driven timeline stepper - a planned next refinement - would let me express a phase's beat sequence as an array of pattern steps rather than procedural callbacks. That is planned, not shipped. Today the pattern is FSM + fire callbacks.
Here is the comparison at a glance:
| Property | Generator coroutine | Finite state machine |
|---|---|---|
| Readability of a sequence | Excellent - straight-line function*
|
Moderate - phase edges + callbacks |
| Per-frame allocation | One generator object + one {value,done} per step |
Zero |
| Serializable state | No - internal slots only | Yes - a string on a component |
| Cancellation semantics |
return() on the generator |
State transition to an end state |
| Best fit | Non-hot-path async, once-through flows | Real-time game logic with save requirements |
As a decision tree:
Game sequencing need
│
└─ In the frame loop?
├─ No → Generator coroutine
└─ Yes → Must survive a save?
├─ Yes → Finite state machine
└─ No → Many concurrent sequences?
├─ Yes → Finite state machine
└─ No → Generator coroutine
Limits
No benchmark exists for the coroutine path. The allocation argument is qualitative, grounded in spec behavior and the engine's zero-alloc discipline - not a measured fps delta. On V8, escape analysis may partially elide the iterResult allocation in some loops; on Hermes, it does not. Either way, the serialization reason stands independently.
The FSM is not a readability win over coroutines. Boss scripts expressed as phase edges and fire callbacks are less obviously sequential than a function*. That trade-off is real. If readability were the only axis, coroutines would have won.
The engine is published to npm as @flare-engine/* version 0.1.0 with restricted access - it is not open source at this stage. The CoroutineManager ships in @flare-engine/core; it is simply unused by the game.
Where coroutines do belong
The coroutine system is not a mistake. It is the right tool on a different layer.
For non-hot-path async - scripting an onboarding flow in a React Native app, sequencing an asset or boot pipeline, running any once-through async glue that is not in the frame loop and does not need to survive a save - a generator coroutine is excellent. The readability benefit is real. The allocation and serialization costs do not apply to a flow that runs once, on startup, outside the 60 fps tick.
This is the exact example for the React Native prototyping angle: generators are how you script onboarding flows without callback hell in React Native. The mistake is not reaching for generators; the mistake is wiring them into the frame loop of a save-capable game and pretending the trade-offs aren't there.
Right tool, wrong layer.
Related
Originally posted on grzegorzotto.dev
Top comments (0)