State Machines
Manage AI behavior with Finite State Machines (FSM) and Hierarchical State Machines (HFSM) supporting nested states, transitions, guards, and history.
Overview#
Web Engine provides two types of state machines:
Simple FSM
Flat state structure with simple transitions, lifecycle hooks (enter/update/exit), generic key types, lightweight and fast.
Hierarchical FSM
Nested/hierarchical states with transition guards, event-driven transitions, history states (shallow and deep), and zero-GC optimization.
Simple State Machine#
Basic FSM#
import { StateMachine, State } from '@web-engine-dev/core/engine/ai'; // Define statesenum AIState { IDLE = 'IDLE', PATROL = 'PATROL', CHASE = 'CHASE', ATTACK = 'ATTACK',} // Create state machineconst fsm = new StateMachine<AIState>(); // Add states with lifecycle hooksfsm.add(AIState.IDLE, { onEnter() { playIdleAnimation(); }, onUpdate(delta, time) { checkForEnemies(); }, onExit() { stopIdleAnimation(); },}); fsm.add(AIState.PATROL, { onEnter() { selectNextWaypoint(); }, onUpdate(delta, time) { moveToWaypoint(delta); if (atWaypoint()) { selectNextWaypoint(); } },}); fsm.add(AIState.CHASE, { onEnter() { playChaseAnimation(); }, onUpdate(delta, time) { chaseTarget(delta); },}); fsm.add(AIState.ATTACK, { onEnter() { startAttack(); }, onUpdate(delta, time) { if (attackComplete()) { fsm.changeTo(AIState.CHASE); } },}); // Start in IDLE statefsm.changeTo(AIState.IDLE); // In game loopfsm.update(delta, time); // Transition to new statesif (enemySpotted) { fsm.changeTo(AIState.CHASE);} if (inAttackRange) { fsm.changeTo(AIState.ATTACK);}FSM System Integration#
import { registerFSM, assignFSM, changeFSMState, getCurrentFSMState,} from '@web-engine-dev/core/engine/ai'; // Register an FSM templateregisterFSM('GuardFSM', () => { const fsm = new StateMachine<AIState>(); fsm.add(AIState.IDLE, { onUpdate(delta) { // Check for targets }, }); fsm.add(AIState.PATROL, { onUpdate(delta) { // Patrol logic }, }); return fsm;}); // Assign to entityassignFSM(eid, 'GuardFSM', AIState.IDLE); // Change state externallychangeFSMState(eid, AIState.PATROL); // Get current stateconst currentState = getCurrentFSMState(eid);console.log('Current state:', currentState);Hierarchical State Machine#
HFSM Basics#
Hierarchical FSM supports nested states with parent-child relationships:
import { HierarchicalStateMachine, HistoryType } from '@web-engine-dev/core/engine/ai'; interface AIContext { targetEid: number; health: number; alertLevel: number;} const fsm = new HierarchicalStateMachine<AIContext>({ initial: 'Idle', states: { Idle: { onEnter: (ctx) => { console.log('Entering idle state'); playIdleAnimation(); }, on: { ENEMY_SPOTTED: 'Combat', PATROL_START: 'Patrol', }, }, Patrol: { initial: 'MovingToWaypoint', states: { MovingToWaypoint: { onUpdate: (ctx, delta) => { moveToWaypoint(delta); }, on: { WAYPOINT_REACHED: 'WaitingAtWaypoint', }, }, WaitingAtWaypoint: { onEnter: () => selectNextWaypoint(), on: { WAIT_COMPLETE: 'MovingToWaypoint', }, }, }, on: { ENEMY_SPOTTED: 'Combat', }, }, Combat: { initial: 'Approach', history: HistoryType.Shallow, // Remember last combat state states: { Approach: { onUpdate: (ctx, delta) => { moveToTarget(ctx.targetEid, delta); }, on: { IN_RANGE: 'Attack', }, }, Attack: { onEnter: (ctx) => { startAttack(ctx.targetEid); }, on: { OUT_OF_RANGE: 'Approach', TARGET_DEAD: 'Search', }, }, Search: { onUpdate: (ctx, delta) => { searchForTargets(); }, on: { ENEMY_SPOTTED: 'Approach', TIMEOUT: '#Idle', // Absolute path to root state }, }, }, on: { HEALTH_CRITICAL: '#Flee', }, }, Flee: { onUpdate: (ctx, delta) => { fleeFromThreat(delta); }, on: { SAFE_DISTANCE: 'Idle', }, }, }, // Global transitions (available from any state) on: { DEATH: 'Dead', },}); // Start the state machineconst context: AIContext = { targetEid: 0, health: 100, alertLevel: 0,}; fsm.start(context); // Update in game loopfsm.update(context, delta, time); // Send events to trigger transitionsfsm.send('ENEMY_SPOTTED', context);fsm.send('IN_RANGE', context); // Check current stateconsole.log('Current state:', fsm.getCurrentState()); // "Combat.Attack"Transition Guards#
Guards control whether a transition should occur:
const fsm = new HierarchicalStateMachine<AIContext>({ initial: 'Idle', states: { Idle: { on: { ENEMY_SPOTTED: { target: 'Combat', guard: (ctx) => ctx.health > 20, // Only enter combat if healthy }, ENEMY_SPOTTED_WEAK: { target: 'Flee', guard: (ctx) => ctx.health <= 20, // Flee if weak }, }, }, Combat: { states: { Attack: { on: { RELOAD: { target: 'Reloading', guard: (ctx) => ctx.ammo === 0, // Only reload when empty action: (ctx) => { console.log('Transitioning to reload'); }, }, }, }, Reloading: { onEnter: (ctx) => startReload(), on: { RELOAD_COMPLETE: 'Attack', }, }, }, }, Flee: { onUpdate: (ctx, delta) => { fleeFromThreat(delta); }, }, },});History States#
History states remember which substate was active:
import { HistoryType } from '@web-engine-dev/core/engine/ai'; const fsm = new HierarchicalStateMachine<AIContext>({ initial: 'Combat', states: { Combat: { initial: 'Approach', history: HistoryType.Shallow, // Remember last combat substate states: { Approach: { on: { IN_RANGE: 'Attack' }, }, Attack: { on: { OUT_OF_RANGE: 'Approach' }, }, }, on: { TAKE_COVER: '#Cover', }, }, Cover: { onUpdate: (ctx, delta) => { hideInCover(); }, on: { SAFE: 'Combat', // Returns to last combat state (Attack or Approach) }, }, },}); // First time: enters Combat.Approach (initial state)fsm.send('TAKE_COVER', context); // Go to Cover// If was in Combat.Attack when we took cover...fsm.send('SAFE', context); // Returns to Combat.Attack (history)History Types:
- None — No history, always enter initial substate
- Shallow — Remember only direct child state
- Deep — Remember full nested state path (all descendants)
HFSM System Integration#
import { registerHFSM, assignHFSM, sendFSMEvent, getCurrentFSMState, isInFSMState,} from '@web-engine-dev/core/engine/ai'; // Register HFSM templateregisterHFSM('EnemyAI', () => { return new HierarchicalStateMachine<AIContext>({ // ... config as above });}); // Assign to entityassignHFSM(eid, 'EnemyAI'); // Send eventssendFSMEvent(eid, 'ENEMY_SPOTTED');sendFSMEvent(eid, 'IN_RANGE'); // Check current stateconst state = getCurrentFSMState(eid);console.log('State:', state); // "Combat.Attack" // Check if in a state (supports partial matching)if (isInFSMState(eid, 'Combat')) { // True for "Combat", "Combat.Attack", "Combat.Approach", etc. console.log('In combat!');}State Machine Context#
FSM Context#
Simple FSM uses context passed to state methods:
import { registerFSM } from '@web-engine-dev/core/engine/ai'; registerFSM('GuardFSM', (eid: number, world: IWorld) => { const fsm = new StateMachine<AIState>(); fsm.add(AIState.PATROL, { onUpdate(delta, time) { // Access entity data const pos = Transform.position[eid]; const vel = Velocity.linear[eid]; // Use blackboard for shared data const blackboardId = AIAgent.blackboardId[eid]; const waypoints = getBlackboardValue(blackboardId, 'waypoints'); }, }); return fsm;});HFSM Context#
HFSM uses a typed context object:
interface EnemyContext { eid: number; targetEid: number; health: number; maxHealth: number; alertLevel: number; lastSeenPosition: THREE.Vector3 | null; ammo: number; maxAmmo: number;} const fsm = new HierarchicalStateMachine<EnemyContext>({ initial: 'Idle', states: { Combat: { states: { Attack: { onUpdate: (ctx, delta) => { // Access context if (ctx.ammo > 0) { shootAtTarget(ctx.eid, ctx.targetEid); ctx.ammo--; } else { // Trigger reload fsm.send('RELOAD', ctx); } }, }, }, }, },}); // Create context for entityconst context: EnemyContext = { eid, targetEid: 0, health: 100, maxHealth: 100, alertLevel: 0, lastSeenPosition: null, ammo: 30, maxAmmo: 30,}; fsm.start(context);Advanced HFSM Features#
Numeric State IDs#
For hot paths, use numeric state IDs for O(1) state checks:
// Get numeric ID onceconst attackStateId = fsm.getStateId('Combat.Attack'); // In hot path (60 FPS loop)if (fsm.isInStateById(attackStateId)) { // O(1) check - no string hashing performAttack();} // Or get current state IDconst currentId = fsm.getCurrentStateId(); // Use in switch statementswitch (currentId) { case attackStateId: performAttack(); break; // ... other states}Event Data#
Pass data with events:
// Define event with datafsm.send('ENEMY_SPOTTED', context, { enemyEid: targetEid, distance: 10.5, position: new THREE.Vector3(5, 0, 5),}); // Access in transition actionconst fsm = new HierarchicalStateMachine<AIContext>({ states: { Idle: { on: { ENEMY_SPOTTED: { target: 'Combat', action: (ctx, event, data: any) => { ctx.targetEid = data.enemyEid; ctx.lastSeenPosition = data.position; console.log(`Enemy at ${data.distance}m`); }, }, }, }, },});State Queries#
// Check if FSM is startedif (fsm.isStarted()) { // Update FSM fsm.update(context, delta, time);} // Get current state pathconst state = fsm.getCurrentState(); // "Combat.Attack" // Check if in state (partial matching)if (fsm.isInState('Combat')) { // True for Combat, Combat.Attack, Combat.Approach, etc.} // Check exact stateif (fsm.getCurrentState() === 'Combat.Attack') { // Exact match only}Integration with Behavior Trees#
FSM within Behavior Tree#
import { BTAction, BTStatus } from '@web-engine-dev/core/engine/ai'; // Create action that runs FSMconst fsmAction = new BTAction('runFSM', (ctx) => { const fsmInstance = getFSMInstance(ctx.eid); if (!fsmInstance) { return BTStatus.FAILURE; } // Update FSM fsmInstance.update(ctx.delta, ctx.time); // Check FSM state to determine BT status const currentState = getCurrentFSMState(ctx.eid); if (currentState === 'Combat.Attack') { return BTStatus.RUNNING; } else if (currentState === 'Idle') { return BTStatus.SUCCESS; } return BTStatus.RUNNING;}); // Use in behavior treeconst tree = new BTSequence([ new BTCondition('hasTarget', (ctx) => ctx.blackboard.has('targetEid')), fsmAction,]);Performance Optimization#
Zero-GC HFSM#
HFSM is optimized for zero garbage collection:
- Pre-computed path segments (no split() calls)
- Pre-allocated buffers for state traversal
- Numeric state IDs for O(1) comparisons
- Cached current state string
- Reused arrays for active states
Update Throttling#
// Throttle FSM updates based on distanceconst distanceToCamera = calculateDistance(eid, cameraPosition); if (distanceToCamera < 20) { // Near: update every frame fsm.update(context, delta, time);} else if (distanceToCamera < 50) { // Mid: update every 0.1s if (time - lastUpdateTime > 0.1) { fsm.update(context, delta, time); lastUpdateTime = time; }} else { // Far: update every 0.5s if (time - lastUpdateTime > 0.5) { fsm.update(context, delta, time); lastUpdateTime = time; }}Debugging State Machines#
State Logging#
// Log all state transitionsconst fsm = new HierarchicalStateMachine<AIContext>({ states: { Idle: { onEnter: (ctx) => { console.log('[FSM] Entered Idle'); }, onExit: (ctx) => { console.log('[FSM] Exited Idle'); }, }, Combat: { onEnter: (ctx) => { console.log('[FSM] Entered Combat'); }, states: { Attack: { onEnter: (ctx) => { console.log('[FSM] Entered Combat.Attack'); }, }, }, }, },});State History#
// Track state history for debuggingconst stateHistory: string[] = []; const fsm = new HierarchicalStateMachine<AIContext>({ states: { Idle: { onEnter: () => { stateHistory.push('Idle'); if (stateHistory.length > 10) stateHistory.shift(); }, }, // ... other states },}); // View state historyconsole.log('State history:', stateHistory.join(' -> '));Example: Complete Enemy AI#
import { HierarchicalStateMachine, HistoryType, registerHFSM, assignHFSM, sendFSMEvent,} from '@web-engine-dev/core/engine/ai'; interface EnemyContext { eid: number; targetEid: number; health: number; alertLevel: number;} // Register enemy AI FSMregisterHFSM('EnemyAI', () => { return new HierarchicalStateMachine<EnemyContext>({ initial: 'Patrol', states: { Patrol: { initial: 'Moving', states: { Moving: { onUpdate: (ctx, delta) => { moveToWaypoint(ctx.eid, delta); }, on: { WAYPOINT_REACHED: 'Waiting', }, }, Waiting: { onEnter: () => selectNextWaypoint(), on: { WAIT_COMPLETE: 'Moving', }, }, }, on: { ENEMY_SPOTTED: { target: 'Combat', guard: (ctx) => ctx.health > 20, action: (ctx, event, data: any) => { ctx.targetEid = data.enemyEid; }, }, }, }, Combat: { initial: 'Approach', history: HistoryType.Shallow, states: { Approach: { onUpdate: (ctx, delta) => { moveToTarget(ctx.eid, ctx.targetEid, delta); }, on: { IN_RANGE: 'Attack', }, }, Attack: { onEnter: (ctx) => { startAttack(ctx.eid, ctx.targetEid); }, on: { OUT_OF_RANGE: 'Approach', TARGET_LOST: 'Search', }, }, Search: { onUpdate: (ctx, delta) => { searchArea(ctx.eid, delta); }, on: { ENEMY_SPOTTED: 'Approach', SEARCH_TIMEOUT: '#Patrol', }, }, }, on: { HEALTH_LOW: { target: '#Flee', guard: (ctx) => ctx.health < 20, }, }, }, Flee: { onUpdate: (ctx, delta) => { fleeFromTarget(ctx.eid, ctx.targetEid, delta); }, on: { SAFE_DISTANCE: 'Patrol', HEALTH_RECOVERED: { target: 'Combat', guard: (ctx) => ctx.health > 50, }, }, }, }, on: { DEATH: 'Dead', }, });}); // Setup enemyfunction createEnemy(eid: number) { // Add AI component addComponent(world, AIAgent, eid); initAIAgent(eid, AIControllerType.StateMachine, AIAffiliation.Hostile); // Assign HFSM assignHFSM(eid, 'EnemyAI'); // Set initial context via blackboard const blackboardId = AIAgent.blackboardId[eid]; setBlackboardValue(blackboardId, 'health', 100); setBlackboardValue(blackboardId, 'alertLevel', 0);} // Send events from game logicfunction onEnemySpotted(eid: number, targetEid: number) { sendFSMEvent(eid, 'ENEMY_SPOTTED', { enemyEid: targetEid });} function onHealthChange(eid: number, health: number) { const blackboardId = AIAgent.blackboardId[eid]; setBlackboardValue(blackboardId, 'health', health); if (health < 20) { sendFSMEvent(eid, 'HEALTH_LOW'); } else if (health > 50) { sendFSMEvent(eid, 'HEALTH_RECOVERED'); }}