fsm
Import from @pierre/ecs/modules/fsm.
A tiny finite-state machine: a serialisable runtime value (Fsm) plus a
per-tick tickFsm that dispatches to a state table of closures. The
machine holds only current + elapsedMs (time-in-state); the
behaviour — transitions and side effects — lives in an FsmStates map
the app owns, exactly like a system.
FSM APIs differ across engines and libraries, so this ships the smallest
useful surface rather than mirroring any one of them. The strong consumer is
examples/stealth-guard — a 5-state
guard brain (patrol→suspicious→chase→search→return) that exercises the
whole surface (enter/exit hooks + elapsedMs time-in-state) and composes
modules/steering in its chase state. doom’s idle/chase/attack enemy
AI is a fitting second consumer (it maps onto the update-only subset)
but has not been migrated yet.
Transition model — per-state update returns next
Section titled “Transition model — per-state update returns next”Each state’s update returns the next state key to transition, or null
to stay. Returning the same key also stays (no re-enter). onEnter /
onExit fire on transition boundaries — reset a timer, pick a target.
update/onEnter/onExit all receive self (the Fsm) for
elapsedMs and current.
interface Fsm<TKey extends string> { current: TKey; elapsedMs: number }interface FsmState<TCtx, TKey extends string> { onEnter?: (ctx: TCtx, self: Fsm<TKey>) => void; onExit?: (ctx: TCtx, self: Fsm<TKey>) => void; update: (ctx: TCtx, self: Fsm<TKey>) => TKey | null;}type FsmStates<TCtx, TKey extends string> = Record<TKey, FsmState<TCtx, TKey>>;
function makeFsm<TKey>(initial: TKey): Fsm<TKey>;function tickFsm<TCtx, TKey>(fsm, states, ctx, dtMs): void;tickFsm order each tick: elapsedMs += dtMs → update → on a returned
key, onExit (old) → switch + reset elapsedMs → onEnter (new).
type State = 'patrol' | 'chase';const states: FsmStates<GameCtx, State> = { patrol: { update: (ctx) => (ctx.canSeeTarget ? 'chase' : null), }, chase: { onEnter: (ctx) => { ctx.alarm(); }, update: (ctx, self) => { if (!ctx.canSeeTarget && self.elapsedMs > 2000) return 'patrol'; return null; }, },};
const fsm = makeFsm<State>('patrol');// each tick, per entity:tickFsm(fsm, states, ctx, dtMs);- Value primitive, not a component. Like
modules/timer,Fsmis a plain value — thestatesmap holds closures that can’t serialise. Put theFsmvalue wherever you like (a component field, a controller object). No ECS coupling; zero dependencies. - Single-machine.
tickFsmadvances one machine. To drive many entities, loop them and calltickFsmper entity, supplying per-entity context (e.g. setctx.currentEntitybefore each call). This keeps thestatestable shared and stateless. - Composes with movement. The FSM is the brain (which behaviour);
pair it with
@pierre/ecs/modules/steeringfor the muscles — e.g. achasestate whose handler drives velocity viaseek.