StateMachine

A hierarchical state machine over a plain context object — the one place a game keeps "what am I doing right now": an enemy brain, a weapon (ready → fire → cooldown → reload), a game mode (warmup → round → over), a door. Bots, senses and squads built on it: the Game AI recipe.

  • give an enemy a brain: patrol, chase, attack, search
  • run a weapon's fire, cooldown and reload cycle
  • switch game modes: warmup, round, game over
  • leave a state after a few seconds
  • react to an event such as a hit, whatever state the bot is in
  • check which state is active and for how long

At a glance

TypeScript
type Bot = { hp: number; target: boolean; visible: boolean; dist: number; lostFor: number; fire: boolean }

const Guard = defineStates<Bot>({
  initial: 'patrol',
  on: { hit: 'flinch' },                                       // an event, from ANY state
  to: { dead: (b) => b.hp <= 0 },                              // tested in every state, first
  states: {
    patrol: { to: { combat: (b) => b.target } },
    combat: {
      initial: 'engage',
      to: { search: (b) => b.lostFor > 4 },                    // leaves whichever child is active
      states: {
        engage: { to: { shoot: (b) => b.visible && b.dist < 12 } },
        shoot:  { enter: (b) => { b.fire = true }, exit: (b) => { b.fire = false }, to: { engage: (b) => !b.visible } },
      },
    },
    search: { after: [6, 'patrol'] },                          // a timed transition
    flinch: { after: [0.2, '$back'] },                         // back to the state we came from
    dead:   { enter: () => console.log('down') },
  },
})

const bot: Bot = { hp: 100, target: false, visible: false, dist: 99, lostFor: 0, fire: false }
const fsm = new StateMachine(Guard, bot)         // enters 'patrol' now
fsm.on('change', (to, from) => console.log(`[fsm] ${from}>${to}`))

bot.target = true
fsm.step(1 / 60)                                 // once per tick: an aspect's update(dt), say
fsm.state                                        // 'combat.engage'
fsm.is('combat')                                 // true while any combat child is active
fsm.send('hit')                                  // → 'flinch'; back to 'combat.engage' 0.2 s later

The states and their transitions: defineStates

The whole machine is ONE declarative table: its states, what enters and leaves each, and when.

TypeScript
defineStates<C>(def: StatesDef<C>): StatesDef<C>     // identity — names the context type once

interface StatesDef<C> {
  initial: string
  states: Record<string, StateDef<C>>
  to?: Record<string, (ctx: C, t: number) => boolean>            // any-state guards, tested first
  on?: Record<string, string | ((ctx: C, ...args) => string | void)> // any-state event fallback
  update?: (ctx: C, dt: number, t: number) => string | void         // runs before the active states' own
}

interface StateDef<C> {
  initial?: string                      // with `states`: the child entered by default (default: the first)
  states?: Record<string, StateDef<C>>  // makes this a compound state; only a leaf is ever "the" state
  enter?: (ctx: C, from: string) => void
  exit?: (ctx: C, to: string) => void
  update?: (ctx: C, dt: number, t: number) => string | void   // return a name to transition
  to?: Record<string, (ctx: C, t: number) => boolean>         // guards: key = target, first true wins
  after?: [seconds: number, target: string]                   // leave after `seconds` in this state
  on?: Record<string, string | ((ctx: C, ...args) => string | void)>
}

t is seconds spent in that state (the root's t = total). Names resolve relative first — the declaring state's siblings (a compound state may also name its own children), then each ancestor's siblings — then as a dotted path from the root ('combat.engage'). '$back' = the leaf active before the last transition; it may be an after / on / update target, not a to key. An unknown name in to, after or a string on throws when the machine is built, so a typo is a startup error, not a silent no-op; a name a function returns resolves (and throws) when it is returned.

Note

the table is shared by every instance — a per-instance duration is a guard on t (to: { ready: (g, t) => t >= g.cooldown }), not an after.

What one tick does: the order inside step(dt)

  1. Every active state's clock advances.
  2. Transitions are tested outermost first: the root's to, then each active state's to guards in declaration order and its after. A taken transition is re-tested from the new state (a pass-through state chains out in the same step), at most 8 hops.
  3. update runs outermost → innermost (root update first). An update that returns a name transitions there and ends the pass; the states it entered update next step.

A transition exits states innermost-first up to the common ancestor and enters the new branch outermost-first, descending into initial children. A compound state's exit is the one place to undo whatever any of its children set.

Note

a guard never re-enters a state that is already active — to: { dead: (b) => b.hp <= 0 } fires once, not every step while it stays true. Only go() and events re-enter (go('patrol') from patrol runs exit then enter).

Run the machine and read its state: StateMachine

The machine knows nothing about aspects, nodes or the network, so its owner ticks it wherever it lives: an updateFixed for anything that must match another machine's, update for presentation.

TypeScript
new StateMachine(def, ctx)               // enters the initial branch now: `enter(ctx, '')`, no `change` event
fsm.step(dt: number): void
fsm.send(event: string, ...args): boolean   // innermost active state first, the root's `on` last; true = handled
fsm.go(name: string): void               // force a transition (a spawn, a reset)
fsm.state: string                        // the active leaf's path
fsm.branch: string[]                     // every active state, outermost first
fsm.previous: string                     // the leaf before the last transition ('' at start)
fsm.time: number                         // seconds in the active leaf
fsm.is(name): boolean                    // active as the leaf OR an ancestor (name or path)
fsm.timeIn(name): number                 // seconds in that active state (0 if inactive)
fsm.on('change', (to, from) => void): this
fsm.off('change', fn): this
fsm.ctx: C

A handler in on that returns nothing still counts as handled — the event stops there; return a name to transition. A handler on dead: { on: { hit: () => undefined } } is how a state swallows an event the root would otherwise act on.

Pitfalls

  • Guards run every step: keep them pure and cheap. A guard with a side effect (seekCover() storing the cover it found) is fine only when its preconditions come first in the && chain.
  • Don't reset the one-shot outputs inside states — clear them once before step() (the brain clears intent.fire; the shoot state re-asserts it).
  • A '$back' into a compound state re-runs that state's enter — make enter re-derive what it needs, or don't route that event there.

See also