XState animation binding
Three.js gives you an AnimationMixer that plays and crossfades clips. vgai adds the layer
it doesn’t have: which clip plays when, driven by an ordinary XState state machine you
already own. There is no VGAI-specific graph runtime or JSON format — bindXStateAnimation
(packages/engine/src/animation/xstate-animation-binding.ts) reads plain meta.animation
data off your machine’s states and drives native AnimationActions as XState transitions
states.
The convention: meta.animation
Section titled “The convention: meta.animation”You write a normal xstate machine. Any state that should drive animation gets a
meta.animation value — either a single clip or a blend tree:
import { assign, setup } from 'xstate';
const characterAnimationMachine = setup({ types: { context: {} as { speed: number; grounded: boolean }, events: {} as { type: 'UPDATE'; speed: number; grounded: boolean } | { type: 'JUMP' }, },}).createMachine({ id: 'character-animation', initial: 'idle', context: { speed: 0, grounded: true }, on: { UPDATE: { actions: assign({ speed: ({ event }) => event.speed, grounded: ({ event }) => event.grounded }) }, }, states: { idle: { meta: { animation: { clip: 'idle', loop: true, crossfade: { duration: 0.2 } } }, on: { JUMP: 'airborne' }, always: { guard: ({ context }) => context.speed > 0.1, target: 'locomotion' }, }, locomotion: { meta: { animation: { blendTree: { type: '1D', parameter: 'speed', children: [ { clip: 'walk', threshold: 1 }, { clip: 'run', threshold: 5 }, ], }, crossfade: { duration: 0.2 }, }, }, on: { JUMP: 'airborne' }, always: { guard: ({ context }) => context.speed <= 0.1, target: 'idle' }, }, airborne: { meta: { animation: { clip: 'jump', loop: false, crossfade: { duration: 0.1 } } }, always: [ { guard: ({ context }) => context.grounded && context.speed > 0.1, target: 'locomotion' }, { guard: ({ context }) => context.grounded, target: 'idle' }, ], }, },});This follows the shipped third-person player machine
(examples/third-person/src/player-animation-machine.ts, trimmed). Note what’s absent: no
graph.setParameter/trigger vocabulary. Gameplay code drives
the machine the normal XState way — actor.send({ type: 'UPDATE', speed, grounded }) and
actor.send({ type: 'JUMP' }) — and always-guarded eventless transitions react to context,
without a second condition language.
meta.animation is deliberately read-only data with no transition vocabulary — Zod schemas
(xstate-animation-meta.ts) are .strict() so an on/target/guard key smuggled into
meta.animation is a loud validation error. XState alone owns states, events, and guards.
Binding the machine to a mixer
Section titled “Binding the machine to a mixer”import { bindXStateAnimation } from '@engine/animation/xstate-animation-binding';import { createActor } from 'xstate';
const actor = createActor(characterAnimationMachine).start();const binding = bindXStateAnimation(actor, mixer, clips); // clips: Map<string, THREE.AnimationClip>
// each frame:binding.tick(dt); // advances the mixer AND any live blend-tree weights
// later:binding.dispose(); // unsubscribes + uncaches every action this binding createdbindXStateAnimation validates every state’s meta.animation and resolves every
referenced clip name against the clips map up front, at bind time — a typo’d clip name
throws immediately, naming the state, rather than only failing once gameplay happens to reach
that state.
An R3F component owns this explicitly: the shipped PlayerCharacter clones its GLTF, builds the
clip map and THREE.AnimationMixer, then binds them in an effect. See
examples/third-person/src/prefabs/Player.tsx. Imperative scene-loader roots may instead receive
their loader-created mixer and clips through their own source-native setup path; there is no
required userData bridge for TSX roots.
Crossfades use native Three primitives, not a hand-rolled weight scheduler
Section titled “Crossfades use native Three primitives, not a hand-rolled weight scheduler”Entering or leaving a state fires one real AnimationAction.crossFadeTo / fadeIn / fadeOut
call — driven by the same mixer.update(dt) that advances clip time. There’s no second,
parallel clock deciding “is the fade done”; Three’s own scheduler is authoritative end to end.
A blend-tree state is, at the instant of entry, represented by one clip — its
heaviest-weighted child — which receives the native crossfade. Sibling children stay silent
until the crossfade window elapses, at which point the full live blend-tree distribution takes
over via setEffectiveWeight every tick, recomputed from the actor’s live context. This is a
documented simplification (Three’s crossFadeTo is strictly 1-to-1; there’s no native N-ary
blend-tree crossfade).
Where the mixer ticks
Section titled “Where the mixer ticks”binding.tick(dt) has exactly the engine’s SystemFn shape. Register it on the engine’s
'animation' phase:
ctx.systems.add('animation', binding.tick);or, in an R3F component, call it from that component’s useFrame. The shipped
PlayerCharacter does this at the default priority after the parent Player computes intent at
-2 (examples/third-person/src/prefabs/Player.tsx). Either way, mixer advancement rides the
root’s one host-driven clock; do not add a wall-clock loop beside it.
Deliberately out of scope
Section titled “Deliberately out of scope”Layers, additive blending, and bone masks are not supported by meta.animation. A single XState
machine drives one mixer’s weight budget; multi-machine/multi-layer composition would be a
distinct, future binding with its own reader, not a dead field on this one.
See also
Section titled “See also”- Blend trees — the 1D/2D/direct math
meta.animation’sblendTreefield reuses. - Inverse kinematics — procedural bone posing layered on top of the pose this binding produces.
- The game loop & phases — the
animationphase.