Vol. 07 · Dispatch2026-08-11

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.

by MetaWorldOS Engineering
TypeScriptGameDevStateMachineDiscriminatedUnionsPokemon

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 ChapterDirector class: a ChapterStep union for the current step, a ChapterEvent discriminated union for everything the world can report, and a single dispatch(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:

  1. Choose a starter (Bulbasaur / Charmander / Squirtle).
  2. Catch three research-species bugs — an ant, a grass-skipper, a moth.
  3. Upgrade your net.
  4. Cross the meadow: reach the forest entrance and win two wild battles.
  5. Find the rare glowing-beetle bait.
  6. 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:

  • ChapterStep is a string-literal union, not a number. You can switch on it and TypeScript exhaustiveness-checks every case. Adding a new step is a compile-time diff, not a hunt.
  • ChapterEvent is a discriminated union, not a bag of optional fields. The bug_caught event carries species; the wild_battle_won event carries species and level. You can’t accidentally dispatch a battle-won without a level, because the type won’t let you construct it.
  • ChapterState holds 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. ChapterState is a plain object with primitives and one string array — JSON.stringify and 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 for caughtBugs” 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.

— Read Next —

Recommended Dispatches

More engineering deep-dives into 3D rendering, physics simulation, and game architecture

Three.js

Zero art assets — building every polygon, texture, and shader in code

Pokémon of the Forest ships no GLB files, no PNG textures, no baked normal maps. Every creature is marching-cubes-meshed from Wyvill metaballs at boot; every leaf is a 2D-canvas Bézier fill baked into a CanvasTexture; every terrain patch is an analytic heightfield sampled onto a PlaneGeometry; every skin material is a MeshPhysicalMaterial with a custom subsurface-wrap term injected via onBeforeCompile. This post walks the seven techniques that let a browser game with ~24k lines of TypeScript render Pallet Town without downloading a single texture.

Read dispatch
Three.js

Rapier boots async, your engine boots sync — here's the seam

Rapier's wasm loader returns a Promise, but a three.js render loop starts on `new`. If you naively `await RAPIER.init()` in the constructor, you get a black screen while wasm downloads. If you fire off `.then(...)` and forget about it, your first ticks crash on `world.step` because the world doesn't exist yet. Tank Battle threads this needle with a `physReady` flag, a purely-cosmetic pre-physics render loop, and a check-and-wait "deploy" gate.

Read dispatch
Three.js

Three ways Rapier tells you two things touched, and when to use which

Rapier surfaces contact detection through three interfaces on top of the same underlying check — the raw event queue, R3F's `onCollisionEnter` prop, and sensor colliders with `onIntersectionEnter`. Same feature, three ergonomics, different tradeoffs. This post walks through each shape with real call sites from Tank Battle and 3D Race, and where each one belongs.

Read dispatch

Get an email when we publish a new post — engineering deep-dives, ~once a month, no marketing.