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#

basic-fsm.ts
typescript
import { StateMachine, State } from '@web-engine-dev/core/engine/ai';
// Define states
enum AIState {
IDLE = 'IDLE',
PATROL = 'PATROL',
CHASE = 'CHASE',
ATTACK = 'ATTACK',
}
// Create state machine
const fsm = new StateMachine<AIState>();
// Add states with lifecycle hooks
fsm.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 state
fsm.changeTo(AIState.IDLE);
// In game loop
fsm.update(delta, time);
// Transition to new states
if (enemySpotted) {
fsm.changeTo(AIState.CHASE);
}
if (inAttackRange) {
fsm.changeTo(AIState.ATTACK);
}

FSM System Integration#

fsm-system.ts
typescript
import {
registerFSM,
assignFSM,
changeFSMState,
getCurrentFSMState,
} from '@web-engine-dev/core/engine/ai';
// Register an FSM template
registerFSM('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 entity
assignFSM(eid, 'GuardFSM', AIState.IDLE);
// Change state externally
changeFSMState(eid, AIState.PATROL);
// Get current state
const currentState = getCurrentFSMState(eid);
console.log('Current state:', currentState);

Hierarchical State Machine#

HFSM Basics#

Hierarchical FSM supports nested states with parent-child relationships:

hfsm-basics.ts
typescript
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 machine
const context: AIContext = {
targetEid: 0,
health: 100,
alertLevel: 0,
};
fsm.start(context);
// Update in game loop
fsm.update(context, delta, time);
// Send events to trigger transitions
fsm.send('ENEMY_SPOTTED', context);
fsm.send('IN_RANGE', context);
// Check current state
console.log('Current state:', fsm.getCurrentState()); // "Combat.Attack"

Transition Guards#

Guards control whether a transition should occur:

transition-guards.ts
typescript
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:

history-states.ts
typescript
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#

hfsm-system.ts
typescript
import {
registerHFSM,
assignHFSM,
sendFSMEvent,
getCurrentFSMState,
isInFSMState,
} from '@web-engine-dev/core/engine/ai';
// Register HFSM template
registerHFSM('EnemyAI', () => {
return new HierarchicalStateMachine<AIContext>({
// ... config as above
});
});
// Assign to entity
assignHFSM(eid, 'EnemyAI');
// Send events
sendFSMEvent(eid, 'ENEMY_SPOTTED');
sendFSMEvent(eid, 'IN_RANGE');
// Check current state
const 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:

fsm-context.ts
typescript
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:

hfsm-context.ts
typescript
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 entity
const 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:

numeric-ids.ts
typescript
// Get numeric ID once
const 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 ID
const currentId = fsm.getCurrentStateId();
// Use in switch statement
switch (currentId) {
case attackStateId:
performAttack();
break;
// ... other states
}

Event Data#

Pass data with events:

event-data.ts
typescript
// Define event with data
fsm.send('ENEMY_SPOTTED', context, {
enemyEid: targetEid,
distance: 10.5,
position: new THREE.Vector3(5, 0, 5),
});
// Access in transition action
const 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#

state-queries.ts
typescript
// Check if FSM is started
if (fsm.isStarted()) {
// Update FSM
fsm.update(context, delta, time);
}
// Get current state path
const 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 state
if (fsm.getCurrentState() === 'Combat.Attack') {
// Exact match only
}

Integration with Behavior Trees#

FSM within Behavior Tree#

fsm-in-bt.ts
typescript
import { BTAction, BTStatus } from '@web-engine-dev/core/engine/ai';
// Create action that runs FSM
const 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 tree
const 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#

throttling.ts
typescript
// Throttle FSM updates based on distance
const 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#

logging.ts
typescript
// Log all state transitions
const 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#

history.ts
typescript
// Track state history for debugging
const stateHistory: string[] = [];
const fsm = new HierarchicalStateMachine<AIContext>({
states: {
Idle: {
onEnter: () => {
stateHistory.push('Idle');
if (stateHistory.length > 10) stateHistory.shift();
},
},
// ... other states
},
});
// View state history
console.log('State history:', stateHistory.join(' -> '));

Example: Complete Enemy AI#

complete-enemy-ai.ts
typescript
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 FSM
registerHFSM('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 enemy
function 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 logic
function 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');
}
}
Documentation | Web Engine