What it does
The engine has no entity classes, so an object's behaviour lives in a brain: a plain object of named states, each with
optional enter, tick and exit callbacks. You pass it as brain when you spawn the object. Every simulate step the
pack runs the current state's tick, and on the handle h.fsm.go switches state (calling the old state's exit and
the new one's enter), h.fsm.is tests it and h.fsm.time counts the seconds spent in it. Write motion in tick,
one-shot effects such as a sound or a shake in enter, and clean-up in exit.
The game's own flow is a different shape, and createPhases handles it. Phases share one context object, and each
lists its way out: edges with a condition, tested in order after the phase's update, or a timed exit after so many
seconds. Changes are queued and applied at the top of the next update, so a phase is never torn down while its own code
is running. A phase can own things, such as a game-over banner, and they are disposed when it ends; timers started in a
phase end with it too. restart() takes a phase's restart point, the death-to-respawn edge that skips an intro.
For an attract loop or a cut scene, createSequence plays named steps for set times and reports how far through the
current step it is, so a title can cycle logo, demo and high scores and fade between them.
Features
- Brains at spawn — named states with
enter,tickandexit, attached with thebrainspawn field. - Time in state —
h.fsm.timecounts seconds since the last change, for "chase for three seconds, then give up". - Typed states —
game.fsm.defineinfers the state names as a union, so a misspeltgotarget is a type error, and a missing state at run time is an error that lists the states the brain has. - Ticks before collision — brains run before
collisionin the same frame, so a state change is seen at once. - Phase graphs — guarded edges, timed exits,
restartAt,in(...names)and the previous phase. - Owned objects and timers —
own,atandevery, all cleared when the phase exits. - Sequences — looping attract cycles or one-shot cut scenes, with
current,frac,go,resetandonStep. - Headless, 2D or 3D — it draws nothing and works in any world.
Good for
- Arcade enemies with patrol, chase, flee and dying states.
- Bosses with phases, where each phase is a state with its own attack pattern and an entry flourish.
- Player controllers with grounded, jumping and dashing states.
- Doors, turrets and pickups that idle, react and then do something else.
- An arcade game's flow: attract, play, dying with a respawn, and game over back to attract.
- Attract modes and cut scenes as timed steps.
Make it yours
A brain is just data, so each kind of enemy is a new object of states, and two enemies can share most of a brain. The
pack has no options: compose it, pass brain at spawn, and import createPhases and createSequence (or use
game.fsm.phases and game.fsm.sequence) when the game needs them.
Two quite different uses: a top-down shooter where every enemy carries a three-state brain that patrols, chases the
player when near and plays a short death; and a game where fsm holds no brains at all and only runs the flow, an
attract sequence that loops logo, demo and scores, then play, dying and game-over phases with a timed return.
A quick look
An enemy with three states, attached at spawn:
const crab = game.fsm.define<'patrol' | 'chase' | 'dying'>({
initial: 'patrol',
states: {
patrol: { tick: (h, dt) => { h.x += 40 * dt; if (near(h, player)) h.fsm.go('chase'); } },
chase: { tick: (h, dt) => moveToward(h, player, 70 * dt) },
dying: { enter: h => game.after(0.25, () => kill(h)) },
},
});
game.spawn({ id: 'crab', x, y, tags: ['enemy'], brain: crab });
Works with
collision— brains tick before it, so a state change counts in the same frame's hits.scoreboard— score, lives and the high-score table that phases test and reset.wave-plan— waves, difficulty and the win rule a play phase drives.title-screen— a title screen drawn for you.flow— ready-drawn title, pause and game-over scenes, when you do not need your own phases.
Good to know
- One brain per object: a second
brain()call replaces the first without calling itsexit. h.fsm.stateis the state's index, a number; useis()orgame.fsm.state(h)for the name.- A
gofrom insideenterworks but re-enters at once, so guard against loops. Ticks run in spawn order, with no priority. - Phases and sequences are not stepped for you: call
update(dt)once a frame. Agoto a phase with no definition enters an empty phase rather than throwing. - For one behaviour with no branches, a plain simulate step over a tag is simpler.






