Animation State Machine
The animation state machine provides race-condition-safe animation graph execution with priority-based transitions, transactional updates, and zero-allocation hot paths. Perfect for character AI and gameplay logic.
Animator Component#
The Animator component links entities to animation state machines:
import { AnimationStateMachine, AnimationGraphRegistry} from '@web-engine-dev/core/animation';import { addComponent } from 'bitecs';import { Animator } from '@web-engine-dev/core/engine/ecs/components'; // Create entity with animatorconst entity = createEntity(world);addComponent(world, entity, Animator); // Load animation graphconst graph = AnimationGraphRegistry.get('character_locomotion'); // Acquire state machine from poolconst config = { maxQueueSize: 16, maxRequestAgeFrames: 10, blendCurve: BlendCurve.SmoothStep, entityId: entity,};const stateMachine = AnimationStateMachine.acquire(config); // Initialize with graph, mixer, and modelstateMachine.initialize(graph, mixer, skinnedMesh); // Store in componentAnimator.stateMachine[entity] = stateMachine;Animation Graph Definition#
Animation graphs define the structure of state machines with nodes, transitions, and parameters:
Graph Structure#
import type { AnimationGraphDef } from '@web-engine-dev/core/animation'; const locomotionGraph: AnimationGraphDef = { id: 'locomotion', name: 'Character Locomotion', // Parameters for transitions and blend trees parameters: [ { name: 'speed', type: 'float', defaultValue: 0 }, { name: 'grounded', type: 'bool', defaultValue: true }, { name: 'jump', type: 'trigger', defaultValue: false }, ], // Animation nodes (clips, blend trees) nodes: [ { id: 'idle', name: 'Idle', type: 'clip', clipName: 'Idle', loop: true, transitions: [ { id: 'idle_to_locomotion', targetNodeId: 'locomotion', duration: 0.2, conditions: [{ parameterName: 'speed', operator: '>', value: 0.1 }], hasExitTime: false, }, { id: 'idle_to_jump', targetNodeId: 'jump', duration: 0.1, conditions: [{ parameterName: 'jump', operator: '==', value: true }], hasExitTime: false, }, ], events: [], }, { id: 'locomotion', name: 'Locomotion Blend', type: 'blendTree1D', parameterName: 'speed', thresholds: [ { clipName: 'Walk', threshold: 1.5 }, { clipName: 'Run', threshold: 4.0 }, { clipName: 'Sprint', threshold: 7.0 }, ], transitions: [ { id: 'locomotion_to_idle', targetNodeId: 'idle', duration: 0.3, conditions: [{ parameterName: 'speed', operator: '<', value: 0.1 }], hasExitTime: false, }, ], events: [], }, { id: 'jump', name: 'Jump', type: 'clip', clipName: 'Jump', loop: false, transitions: [ { id: 'jump_to_idle', targetNodeId: 'idle', duration: 0.2, conditions: [{ parameterName: 'grounded', operator: '==', value: true }], hasExitTime: true, exitTime: 0.9, // Transition near end of jump }, ], events: [ { name: 'jump_start', time: 0.0, payload: { force: 5 } }, { name: 'jump_land', time: 0.9, payload: null }, ], }, ], entryNodeId: 'idle', // Start in idle state};States and Nodes#
The state machine supports three node types:
Clip Nodes#
Play a single animation clip with optional speed and looping:
- clipName - Name of clip in AnimationRegistry
- speed - Playback speed multiplier (default 1.0)
- loop - Whether to loop clip (default false)
- rootMotion - Enable root motion extraction
BlendTree1D Nodes#
Blend between clips based on a single parameter:
- parameterName - Parameter to blend on
- thresholds - Array of {clipName, threshold}
BlendTree2D Nodes#
Blend in 2D parameter space for directional movement:
- parameterX - First parameter (e.g., velocityX)
- parameterY - Second parameter (e.g., velocityZ)
- thresholds - Array of {clipName, position: [x, y]}
Transitions#
Transitions define how the state machine moves between nodes:
Transition Properties#
| Property | Type | Description |
|---|---|---|
| id | string | Unique transition identifier |
| targetNodeId | string | Node to transition to |
| duration | number | Crossfade duration in seconds |
| conditions | Condition[] | All must be true to trigger |
| hasExitTime | boolean | Require normalized time before transition |
| exitTime | number | Normalized time (0-1) to allow transition |
Transition Priority#
The state machine uses priority levels to handle interruptions:
import { TransitionPriority } from '@web-engine-dev/core/animation'; // Queue normal transitionstateMachine.queueTransition( 'walk', 0.3, TransitionPriority.Normal // Can be interrupted); // Queue high-priority attack (interrupts walk)stateMachine.queueTransition( 'attack', 0.1, TransitionPriority.Action, // Higher priority { interruptible: false } // Cannot be interrupted); // Force immediate transition (interrupts everything)stateMachine.queueTransition( 'death', 0.0, TransitionPriority.Immediate);Priority Levels#
- Normal (0) - Gameplay transitions (walk → run)
- Action (1) - Triggered actions (attack, jump)
- Reaction (2) - External reactions (hit, stagger)
- Forced (3) - Forced state changes (death, teleport)
- Immediate (4) - Always executes immediately
Parameters and Conditions#
Parameter Types#
| Type | Value | Use Case |
|---|---|---|
| float | number | Speed, angle, blend weight |
| int | number | State index, combo counter |
| bool | boolean | Grounded, attacking, dead |
| trigger | boolean | Jump, attack (auto-reset) |
Setting Parameters#
// Set individual parameterstateMachine.setParameter('speed', velocity.length());stateMachine.setParameter('grounded', isGrounded); // Batch update (more efficient)const updates = new Map([ ['speed', 3.5], ['grounded', true], ['jump', false],]);stateMachine.updateParameters(updates); // Get parameter valueconst currentSpeed = stateMachine.getParameter('speed');Condition Operators#
Conditions evaluate parameter values with comparison operators:
- > - Greater than
- < - Less than
- == - Equal to
- != - Not equal to
- >= - Greater than or equal
- <= - Less than or equal
// Multiple conditions (ALL must be true)const transition = { targetNodeId: 'sprint', duration: 0.2, conditions: [ { parameterName: 'speed', operator: '>', value: 6.0 }, { parameterName: 'stamina', operator: '>', value: 10 }, { parameterName: 'crouching', operator: '==', value: false }, ], hasExitTime: false,}; // Transition triggers when speed > 6 AND stamina > 10 AND not crouchingSub-State Machines#
Complex AI can be organized hierarchically with sub-state machines:
// Main graphconst mainGraph: AnimationGraphDef = { id: 'main', name: 'Main Character Controller', parameters: [ { name: 'combat_mode', type: 'bool', defaultValue: false }, ], nodes: [ { id: 'locomotion_subgraph', type: 'subStateMachine', graphId: 'locomotion', // References separate graph transitions: [ { targetNodeId: 'combat_subgraph', conditions: [{ parameterName: 'combat_mode', operator: '==', value: true }], }, ], }, { id: 'combat_subgraph', type: 'subStateMachine', graphId: 'combat', transitions: [ { targetNodeId: 'locomotion_subgraph', conditions: [{ parameterName: 'combat_mode', operator: '==', value: false }], }, ], }, ], entryNodeId: 'locomotion_subgraph',}; // Locomotion sub-graph (idle, walk, run)const locomotionGraph: AnimationGraphDef = { id: 'locomotion', // ... locomotion states}; // Combat sub-graph (idle, attack, block, dodge)const combatGraph: AnimationGraphDef = { id: 'combat', // ... combat states};State Machine Complexity
Keep root state machines under 20 nodes. Use sub-state machines for complex AI (e.g., separate graphs for locomotion, combat, interaction, vehicle).
Animation Events#
Animation events trigger callbacks at specific times during playback:
Event Definition#
const attackClip: AnimationClipNode = { id: 'attack', name: 'Sword Attack', type: 'clip', clipName: 'Attack_Sword', events: [ { time: 0.3, // 30% through animation name: 'swing_start', payload: { damage: 10, radius: 1.5 }, }, { time: 0.6, // 60% through animation name: 'hit_check', payload: { hitbox: 'sword_blade' }, }, { time: 0.9, // Near end name: 'attack_complete', payload: null, }, ], transitions: [],};Event Handling#
import type { AnimationRuntimeEvent } from '@web-engine-dev/core/animation'; // Update state machineconst result = stateMachine.update(delta); // Flush buffered eventsstateMachine.flushEvents((event: AnimationRuntimeEvent) => { console.log(`Event: ${event.eventName} at ${event.time.toFixed(2)}s`); switch (event.eventName) { case 'swing_start': playSwingSound(); break; case 'hit_check': checkHitbox(event.payload.hitbox); break; case 'attack_complete': resetCombo(); break; }});Event Buffering
Events are buffered during state machine update to prevent re-entrant modifications. Always call flushEvents() after update() to process buffered events.
State Machine Update#
The state machine processes transitions and evaluates blend trees each frame:
// In animation systemfunction updateAnimationSystem(world, delta) { const query = defineQuery([Animator]); const entities = query(world); // Increment global frame counter incrementGlobalFrame(); for (const eid of entities) { const stateMachine = Animator.stateMachine[eid]; // 1. Update parameters from gameplay const velocity = getVelocity(eid); stateMachine.setParameter('speed', velocity.length()); stateMachine.setParameter('grounded', isGrounded(eid)); // 2. Evaluate transitions (auto-triggers based on conditions) stateMachine.evaluateTransitions(); // 3. Update state machine const result = stateMachine.update(delta); // 4. Handle state changes if (result.transitionStarted) { console.log(`Transition started: ${stateMachine.getTargetNodeId()}`); } if (result.transitionCompleted) { console.log(`Entered state: ${result.newNodeId}`); } // 5. Flush events stateMachine.flushEvents(handleAnimationEvent); // 6. Update Three.js mixer const mixer = Animator.mixer[eid]; mixer.update(delta); }}Race Condition Prevention#
The state machine eliminates common race conditions:
Addressed Race Conditions#
- Multiple transitions same frame - Priority queue with deduplication
- State changes during evaluation - Transactional state updates
- Async clip loading - Pending clip tracking with retry
- Event callbacks modifying state - Event buffering with flush
- Parameter updates during blend - Snapshot-based evaluation
Thread Safety Features#
- Lock-free design (single-threaded main loop)
- Re-entrant call protection (locked during update)
- Deferred operations for locked state
- Atomic blend weight updates
Performance Characteristics#
| Operation | Complexity | Target Time |
|---|---|---|
| State machine update | O(1) | < 0.1ms |
| Transition queue insert | O(n) | < 0.01ms (n < 16) |
| Condition evaluation | O(c) | < 0.005ms (c < 4) |
| Event buffering | O(e) | < 0.01ms (e < 8) |
Best Practices#
Graph Design#
- Keep state count under 20 per graph
- Use sub-state machines for complex AI (> 20 states)
- Name states descriptively (e.g., "Walk_Forward", "Attack_Combo1")
- Group related states in sub-graphs (locomotion, combat, interaction)
Transition Design#
- Use exit time for natural flow (jump, attack combos)
- Prefer short crossfades (0.1-0.3s) for responsiveness
- Use priority levels for interruptions
- Limit conditions to 3-4 per transition (readability)
Parameter Management#
- Batch parameter updates with updateParameters()
- Use triggers for one-shot events (jump, attack)
- Reset triggers after consumption
- Cache parameter snapshots for consistent evaluation
Memory Management#
- Acquire state machines with AnimationStateMachine.acquire()
- Release with AnimationStateMachine.release() on despawn
- Monitor pool stats with getPoolStats()
- Clear event buffer after flush