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
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 laterThe states and their transitions: defineStates
The whole machine is ONE declarative table: its states, what enters and leaves each, and when.
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.
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)
- Every active state's clock advances.
- Transitions are tested outermost first: the root's
to, then each active state'stoguards in declaration order and itsafter. A taken transition is re-tested from the new state (a pass-through state chains out in the same step), at most 8 hops. updateruns outermost → innermost (rootupdatefirst). 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.
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.
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: CA 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 clearsintent.fire; theshootstate re-asserts it). - A
'$back'into a compound state re-runs that state'senter— makeenterre-derive what it needs, or don't route that event there.
See also
- cookbook/game-ai-state-machines.md — bots, weapons and a game mode built on this
- aspects.md, systems.md — where to tick a machine and in which phase