Web Engine Docs
Preparing documentation
Use the search bar to quickly find any topic
Preparing documentation
Use the search bar to quickly find any topic
Manage AI behavior with Finite State Machines (FSM) and Hierarchical State Machines (HFSM) supporting nested states, transitions, guards, and history.
Web Engine provides two types of state machines:
Flat state structure with simple transitions, lifecycle hooks (enter/update/exit), generic key types, lightweight and fast.
Nested/hierarchical states with transition guards, event-driven transitions, history states (shallow and deep), and zero-GC optimization.
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);}
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 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 statestates: {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"
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 emptyaction: (ctx) => {console.log('Transitioning to reload');},},},},Reloading: {onEnter: (ctx) => startReload(),on: {RELOAD_COMPLETE: 'Attack',},},},},Flee: {onUpdate: (ctx, delta) => {fleeFromThreat(delta);},},},});
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 substatestates: {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:
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!');}
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 dataconst pos = Transform.position[eid];const vel = Velocity.linear[eid];// Use blackboard for shared dataconst blackboardId = AIAgent.blackboardId[eid];const waypoints = getBlackboardValue(blackboardId, 'waypoints');},});return fsm;});
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 contextif (ctx.ammo > 0) {shootAtTarget(ctx.eid, ctx.targetEid);ctx.ammo--;} else {// Trigger reloadfsm.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);
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 hashingperformAttack();}// Or get current state IDconst currentId = fsm.getCurrentStateId();// Use in switch statementswitch (currentId) {case attackStateId:performAttack();break;// ... other states}
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`);},},},},},});
// Check if FSM is startedif (fsm.isStarted()) {// Update FSMfsm.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}
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 FSMfsmInstance.update(ctx.delta, ctx.time);// Check FSM state to determine BT statusconst 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,]);
HFSM is optimized for zero garbage collection:
// Throttle FSM updates based on distanceconst distanceToCamera = calculateDistance(eid, cameraPosition);if (distanceToCamera < 20) {// Near: update every framefsm.update(context, delta, time);} else if (distanceToCamera < 50) {// Mid: update every 0.1sif (time - lastUpdateTime > 0.1) {fsm.update(context, delta, time);lastUpdateTime = time;}} else {// Far: update every 0.5sif (time - lastUpdateTime > 0.5) {fsm.update(context, delta, time);lastUpdateTime = time;}}
// 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');},},},},},});
// 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(' -> '));
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 componentaddComponent(world, AIAgent, eid);initAIAgent(eid, AIControllerType.StateMachine, AIAffiliation.Hostile);// Assign HFSMassignHFSM(eid, 'EnemyAI');// Set initial context via blackboardconst 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');}}