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:

animator-setup.ts
typescript
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 animator
const entity = createEntity(world);
addComponent(world, entity, Animator);
// Load animation graph
const graph = AnimationGraphRegistry.get('character_locomotion');
// Acquire state machine from pool
const config = {
maxQueueSize: 16,
maxRequestAgeFrames: 10,
blendCurve: BlendCurve.SmoothStep,
entityId: entity,
};
const stateMachine = AnimationStateMachine.acquire(config);
// Initialize with graph, mixer, and model
stateMachine.initialize(graph, mixer, skinnedMesh);
// Store in component
Animator.stateMachine[entity] = stateMachine;

Animation Graph Definition#

Animation graphs define the structure of state machines with nodes, transitions, and parameters:

Graph Structure#

animation-graph.ts
typescript
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#

PropertyTypeDescription
idstringUnique transition identifier
targetNodeIdstringNode to transition to
durationnumberCrossfade duration in seconds
conditionsCondition[]All must be true to trigger
hasExitTimebooleanRequire normalized time before transition
exitTimenumberNormalized time (0-1) to allow transition

Transition Priority#

The state machine uses priority levels to handle interruptions:

transition-priority.ts
typescript
import { TransitionPriority } from '@web-engine-dev/core/animation';
// Queue normal transition
stateMachine.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#

TypeValueUse Case
floatnumberSpeed, angle, blend weight
intnumberState index, combo counter
boolbooleanGrounded, attacking, dead
triggerbooleanJump, attack (auto-reset)

Setting Parameters#

parameters.ts
typescript
// Set individual parameter
stateMachine.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 value
const 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
conditions.ts
typescript
// 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 crouching

Sub-State Machines#

Complex AI can be organized hierarchically with sub-state machines:

sub-state-machines.ts
typescript
// Main graph
const 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#

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

event-handling.ts
typescript
import type { AnimationRuntimeEvent } from '@web-engine-dev/core/animation';
// Update state machine
const result = stateMachine.update(delta);
// Flush buffered events
stateMachine.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:

update-system.ts
typescript
// In animation system
function 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#

OperationComplexityTarget Time
State machine updateO(1)< 0.1ms
Transition queue insertO(n)< 0.01ms (n < 16)
Condition evaluationO(c)< 0.005ms (c < 4)
Event bufferingO(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
Animation | Web Engine Docs | Web Engine Docs