A Pokémon chapter as a 120-line typed state machine
Pokémon of the Forest's opening chapter — pick a starter, catch three research bugs, upgrade your net, cross the meadow, find rare bait, defeat the guardian — runs on a single ChapterDirector class that accepts a discriminated-union event and either advances a step, mutates a side condition, or rejects the event with a typed error code. No flags scattered across the codebase, no "did we already do this" audits, no invalid save states.
TL;DR — Chapter One of Pokémon of the Forest is six ordered quest steps plus a couple of side conditions. Instead of scattering booleans through the world, spawner, and battle system, the whole thing is one
ChapterDirectorclass: aChapterStepunion for the current step, aChapterEventdiscriminated union for everything the world can report, and a singledispatch(event)method that returns{ok:true, transitioned}or{ok:false, code}. The rest of the engine talks to the director; the director owns the truth.Code:
src/features/gamecenter/games/senlin-baokemeng/engine/chapter/
The opening chapter of Pokémon of the Forest has a fixed narrative shape:
- Choose a starter (Bulbasaur / Charmander / Squirtle).
- Catch three research-species bugs — an ant, a grass-skipper, a moth.
- Upgrade your net.
- Cross the meadow: reach the forest entrance and win two wild battles.
- Find the rare glowing-beetle bait.
- Activate the guardian checkpoint, then defeat the guardian.
That’s a linear quest chain with two wrinkles: step 4 has two independent conditions that must both fire, and step 6 requires an ordering (checkpoint before defeat). Everything else is “do the thing, advance the step.”
The obvious wrong way to build this is scatter. hasStarter on the player, caughtSpecies: Set<string> on the bug spawner, netLevel on the tool, wildWins on the battle system, entranceReached on the world, guardianCheckpoint on the battle scene — and then a bunch of if (hasStarter && caughtSpecies.size === 3 && netLevel === 1 && …) checks anywhere UI needs to know what to prompt next. That code path grows a bug every time you add a quest step.
The right shape is a single owner.
The three types that carry everything
// chapter/types.ts
export type ChapterStep =
| 'choose_starter'
| 'collect_samples'
| 'upgrade_net'
| 'cross_meadow'
| 'find_rare_bait'
| 'defeat_guardian'
| 'chapter_complete'
export interface ChapterState {
step: ChapterStep
samples: ResearchSpeciesId[]
meadowWins: number
entranceReached: boolean
guardianCheckpoint: boolean
}
export type ChapterEvent =
| { type: 'starter_chosen'; species: StarterId }
| { type: 'bug_caught'; species: string }
| { type: 'net_upgraded'; level: 1 }
| { type: 'wild_battle_won'; species: WildSpeciesId; level: number }
| { type: 'forest_entrance_reached' }
| { type: 'quest_bait_caught'; item: 'glowing-beetle' }
| { type: 'guardian_checkpoint_activated' }
| { type: 'guardian_defeated' }
Three things to notice:
ChapterStepis a string-literal union, not a number. You canswitchon it and TypeScript exhaustiveness-checks every case. Adding a new step is a compile-time diff, not a hunt.ChapterEventis a discriminated union, not a bag of optional fields. Thebug_caughtevent carriesspecies; thewild_battle_wonevent carriesspeciesandlevel. You can’t accidentally dispatch a battle-won without a level, because the type won’t let you construct it.ChapterStateholds only what the director needs. Player HP, inventory, wallet — all of that lives elsewhere. The director doesn’t care that you have 47 bells; it cares that you caught the glowing beetle.
dispatch(event) is the whole API
// chapter/ChapterDirector.ts
export class ChapterDirector {
private current: ChapterState
dispatch(event: ChapterEvent): DispatchResult {
const beforeStep = this.current.step
const accepted = this.apply(event)
if (!accepted.ok) {
return { ...accepted, state: this.state }
}
return {
ok: true,
transitioned: beforeStep !== this.current.step,
state: this.state,
}
}
private apply(event: ChapterEvent): { ok: true } | { ok: false; code: 'event_not_allowed' | 'invalid_payload' } {
switch (this.current.step) {
case 'choose_starter':
if (event.type !== 'starter_chosen') return { ok: false, code: 'event_not_allowed' }
this.advance()
return { ok: true }
case 'collect_samples':
if (event.type !== 'bug_caught') return { ok: false, code: 'event_not_allowed' }
if (!isResearchSpecies(event.species)) return { ok: true }
if (!this.current.samples.includes(event.species)) {
this.current.samples.push(event.species)
this.current.samples.sort(
(l, r) => RESEARCH_SPECIES.indexOf(l) - RESEARCH_SPECIES.indexOf(r),
)
}
if (this.current.samples.length === RESEARCH_SPECIES.length) this.advance()
return { ok: true }
// ...
}
}
}
That’s the pattern for every step. Three payoffs come out of it:
1. Out-of-order events are event_not_allowed, not silent bugs. If the bug spawner reports bug_caught while the player is still on choose_starter, the director returns {ok: false, code: 'event_not_allowed'}. The bug isn’t credited toward research, but the game doesn’t crash and no other subsystem sees a stale flag. Every caller can decide whether to surface the rejection (usually: don’t).
2. Non-research bugs are ok: true with no state change. During collect_samples, catching a caterpie isn’t wrong — it’s just uninteresting. The director accepts the event, does nothing, and returns ok: true. Callers don’t need to filter by species before dispatching; the director filters. The rest of the engine gets to be dumb.
3. Sample list is deduped and canonically ordered. samples.push guards with includes, then re-sorts by the fixed RESEARCH_SPECIES order. The UI can render state.samples directly as a checklist without sorting or de-duping — same list every render, so React’s key-based reconciliation stays stable.
Where the “wrinkles” live
Meadow crossing needs two things.
case 'cross_meadow':
if (event.type === 'forest_entrance_reached') {
this.current.entranceReached = true
} else if (event.type === 'wild_battle_won') {
if (!Number.isInteger(event.level) || event.level < 3 || event.level > 5) {
return { ok: false, code: 'invalid_payload' }
}
this.current.meadowWins = Math.min(2, this.current.meadowWins + 1)
} else {
return { ok: false, code: 'event_not_allowed' }
}
if (this.current.entranceReached && this.current.meadowWins === 2) this.advance()
return { ok: true }
The step accepts two event types and requires both side conditions before advancing. meadowWins is clamped at 2 with Math.min so a rage-quit save-scummer who over-wins doesn’t fail an === 2 equality check later. entranceReached and meadowWins are independent — reaching the entrance early and battling later works fine, so does battling twice before reaching the entrance.
The level range check gives us invalid_payload — a separate error code from event_not_allowed — so the battle system can distinguish “you sent a battle-won at the wrong time” from “you sent a battle-won with a level=99 typo.”
Guardian needs an ordering.
case 'defeat_guardian':
if (event.type === 'guardian_checkpoint_activated') {
this.current.guardianCheckpoint = true
return { ok: true }
}
if (event.type === 'guardian_defeated' && this.current.guardianCheckpoint) {
this.advance()
return { ok: true }
}
return { ok: false, code: 'event_not_allowed' }
You can’t win the guardian fight before you’ve activated the checkpoint. If the battle system reports guardian_defeated first — say, dev cheat, corrupted save, or race condition — the director rejects it. The narrative can’t fall out of order.
The linkage table is one file
Every step’s successor lives in QuestDefinitions.ts:
export const QUEST_DEFINITIONS: Readonly<Record<ChapterStep, QuestDefinition>> = {
choose_starter: { id: 'choose_starter', next: 'collect_samples' },
collect_samples: { id: 'collect_samples', next: 'upgrade_net' },
upgrade_net: { id: 'upgrade_net', next: 'cross_meadow' },
cross_meadow: { id: 'cross_meadow', next: 'find_rare_bait' },
find_rare_bait: { id: 'find_rare_bait', next: 'defeat_guardian' },
defeat_guardian: { id: 'defeat_guardian', next: 'chapter_complete' },
chapter_complete:{ id: 'chapter_complete', next: null },
}
advance() looks up next and throws if it’s null:
private advance(): void {
const next = QUEST_DEFINITIONS[this.current.step].next
if (next === null) throw new Error(`[chapter] ${this.current.step} has no next step`)
this.current.step = next
}
Reordering the chapter is one edit to this table plus the switch. There’s no “list of quest ids” in three files that can drift out of sync.
Immutable reads, mutable core
state is a getter that returns a clone:
get state(): ChapterState {
return cloneState(this.current)
}
function cloneState(state: ChapterState): ChapterState {
return { ...state, samples: [...state.samples] }
}
Callers cannot mutate director state by accident. The React panel that renders the quest checklist reads runtime.director.state.samples and can pass it directly to useMemo deps — it’s a fresh array reference every read, so Array.prototype.includes in the panel doesn’t trigger a “same object, missed update” bug. Inside the director, mutation on this.current is fine because nothing else has a reference to it.
The samples array specifically is cloned in cloneState because it’s the only nested reference in ChapterState. If the state grew a nested object, the clone would grow with it — but the state is deliberately kept shallow to make that a one-line change.
What the rest of the engine looks like now
BugSpawner.ts doesn’t know what “research bugs” means. When you net a bug it does:
runtime.director.dispatch({ type: 'bug_caught', species: bug.species })
BattleSystem.ts doesn’t track meadow wins:
runtime.director.dispatch({
type: 'wild_battle_won',
species: opponent.species,
level: opponent.level,
})
World.ts doesn’t know how many bugs the player has caught; it just fires:
runtime.director.dispatch({ type: 'forest_entrance_reached' })
The HUD reads director.state.step and renders the current objective. The chapter-summary card reads director.state.samples and renders the checklist. If either display needs a new piece of info, we add a field to ChapterState — not another subscription across three systems.
What this pattern buys you
- New quest step: one file. Add a case to the switch, a row to the definitions table, an event variant to the union.
- Save/load is trivial.
ChapterStateis a plain object with primitives and one string array —JSON.stringifyand back.ChapterDirector.restore(state)reconstructs the exact runtime. - Debugging is a printout.
console.log(director.state)tells you exactly where the chapter is and what’s outstanding. No “grep the codebase forcaughtBugs” archaeology. - The rest of the engine gets simpler. Systems only need to report events, not interpret quest logic. The spawner doesn’t need to know what’s a research species; the battle system doesn’t need to know what counts toward meadow wins.
The whole director is 121 lines. The chapter it runs takes players ~15 minutes.
When not to reach for this
If your chapter is a directed graph (branching narratives, optional side quests, skippable content), the switch-on-step pattern gets awkward — you end up with a step per branch, or a step whose “advance” condition depends on which of several events fired first. At that point you want something closer to a proper state chart (XState, or a hand-rolled Map<Step, Map<EventType, Handler>>).
For Chapter One of Pokémon of the Forest, the graph is a straight line with two forks that rejoin. A switch is the right tool. When Chapter Two adds route branching, the director will grow — but the switch shape holds until it doesn’t, and the discriminated-union events survive the refactor.
Try it live: Pokémon of the Forest — the whole chapter runs on this director.