# Recipe — character animation (graph → rig → ground)

**What this wires:** a rigged character that moves the way the game plays: clips chosen by data, feet that meet the ground, and gameplay moments keyed to animation frames. There is no default hero, soldier or NPC rig: every game picks its clips, parameters and corrections.

## The seams

- **Skeleton standard and retargeting.** `SKELETON_STANDARD` (`@jgengine/core/anim/skeletonStandard`) names one humanoid bone vocabulary slot by slot: `root`, `hips`, `spine`, `chest`, `head`, `upperarm.l/r`, `lowerarm.l/r`, `hand.l/r`, `handslot.l/r`, `upperleg.l/r`, `lowerleg.l/r`, `foot.l/r`, `toes.l/r`, parented in that order (`SKELETON_PARENT`). It is the CC0 KayKit character rig — one armature shared by `kaykit-adventurers/Barbarian|Knight|Mage|Rogue|Rogue_Hooded` and `kaykit-skeletons/Skeleton_Mage|Minion|Rogue|Warrior`, so all nine characters play one authored graph, one foot-IK rig, one attachment slot and one ragdoll layout. It is a naming standard, not a rig choice: a game still picks its own characters and clips.
  - `mapRigToStandard(boneNames, scheme)` maps a rig on another scheme onto those slots through `RIG_SCHEMES` (`kaykit-humanoid`, `mixamo`, `quaternius-alien`) or a game-supplied `RigScheme`. Matching ignores case and punctuation and strips the `mixamorig`/`Armature` scope prefixes. The result is `{ slots, missing, unmapped }`: slots the rig cannot fill are reported, never borrowed, and extra bones (helpers, IK targets, props) come back in `unmapped`.
  - The Quaternius sci-fi aliens are `Root` + `Spine_1…Spine_6` + `Neck` + `Head` with no arms and no legs, so `QUATERNIUS_ALIEN_SCHEME` fills `root`/`hips`/`spine`/`chest`/`head` and reports every limb slot missing. That is the honest mapping — the pack's own clip set is `Alien_Idle` alone.
  - `retargetTracks(tracks, map)` renames a clip's tracks onto the mapped rig and drops tracks whose bone the rig lacks; it emits the **runtime** bone name (`upperlegl`), which is the spelling both the loaded hierarchy and the loaded clips use, since GLTFLoader strips `.` from node names. `runtimeBoneName` gives that spelling for an authored name. `retargetBoneScales(source, target)` gives the per-slot length ratio so a retargeted clip moves both rigs the same distance, not the same angle.
  - Retargeting here is track renaming plus proportion scaling. It does not transfer a pose, compensate a different bind orientation, or supply IK; a rig whose rest orientation differs needs its own correction.
- **Clips on a model.** `ModelConfig.animation` on an entity kind. A catalog model with indexed `clips` animates automatically through semantic clip roles; `"none"` holds the bind pose. `{ clip, paused, time }` holds one pose. Equivalent same-order animation data survives ordinary React rerenders without restarting gait, pending triggers or visual variant choices. The hook retains a private serialized snapshot; intentional clip, playback or graph edits restart that model’s playback. Keep authored one-shot order: it decides priority among simultaneous non-death triggers. Changing the scene, imported clips, context or instance identity starts a new animation lifecycle.
- **Playback clock.** An entity-bound model (`instanceId` with a game context) holds its animation while game time is paused or has zero timescale. Resume continues the existing mixer/graph and queued triggers. Omitted `animation.clock` or `clock: "real"` retains real-time playback. Choose `{ auto: true, clock: "game" }` for a character whose gait and clip progress should follow locally accepted simulation time. The shell samples `ctx.time.advancedSeconds()` rather than multiplying the raw render delta: clamped frames, fixed steps with no accepted tick and dropped catch-up time cannot advance clips ahead of movement. Accepted seconds already include play speed and timescale, exclude calendar compression, and never hydrate or serialize with saved clock offsets. Held frames checkpoint progress so resume does not replay their elapsed time; `animation.timeScale` remains a separate authored visual rate. Context-hosted unbound previews and previews without a context keep using real time.
  - `createSimClock` supplies the allocation-free progress getter. The getter is optional on external structural clocks; selecting game playback without one produces a repair warning and holds the pose. Provide that clock capability or choose real-time playback.
  - This is local accepted progress. Hosted prediction with `advanceTime: false` contributes no progress; it does not supply an accepted remote timeline. Keep real-time playback for those clients unless their clock supplies the intended progress. No snapshot, persistence or signaling format changes are required by the local policy.
- **Automatic roles with controls.** `animation: "auto"` retains the existing defaults. `{ auto: true, clock: "game" }` derives those same roles and one-shot variants from the loaded rig, then applies explicit configuration fields. Explicit `states`, `oneShots` and `graph` replace their whole fields rather than merging mappings; empty maps retain their meaning. A rig without an identifiable idle does not acquire a guessed first-clip fallback. In the inspector's Custom playback controls, select the clock and optionally derive loaded clip roles; selecting a single clip clears automatic derivation. Both settings are ordinary `meta.animation` data and use the same strict reader, undo, save and reload path as other animation controls.
- **Modular visibility.** `ModelConfig.hiddenNodes: ["Knife", "Rogue_Cape"]` hides named visual subtrees on that model instance. Ordinary entity models and custom `useModelInstance` hosts share the selection. Names resolve through `resolveRigNode`; missing or ambiguous names warn and leave the body intact. Malformed lists reject before model or texture allocation. Imported invisible nodes stay invisible, and omitting the field or passing `[]` preserves imported visibility. This does not delete bones, retarget clips or mask skinned vertices. The selected visible bind bounds drive `targetHeight` and grounding before animation; catalog dimensions remain source metadata. Reordered or duplicate names retain the clone; a changed selection replaces only that instance and restarts its visual animation lifecycle. Choose names from the actual asset; instanced crowd/scatter renderers do not implement this selection.
- **Placement.** `targetHeight` normalizes skin-applied imported bind bounds and grounds them at `ModelConfig.y`, then `scale` multiplies that height. The renderer caches skin-applied vertices before animation and shares them between clones, so skin deformation cannot change normalization or fitted collision triangles. Imported root transforms stay inside a separate placement frame shared by render bounds and collision triangles; clips keep binding to the imported root.
  - Editor clip and graph previews use this same placement frame through `useModelInstance`; they pose its `content` and mount its `scene` with the returned `scale` and `position`. Clip metadata uses the shared loader cache, and closing a preview releases only its own materials and bone textures.
  - Heightfield player collision has its own `movement.collisionHeight` (positive meters from feet; default 1.8). Choose it for the game's physical body and author it through a supplied `createMovementSchema` catalog field. Rendered `targetHeight`/`scale` and camera target/eye height remain independent; they do not silently resize the motor. Capsule and voxel controllers keep their declared dimensions.
- **Movement authoring.** A game-named movement catalog can expose the motor's existing jump buffer, coyote grace, release factor, apex/fall gravity and landing recovery controls through supplied `createMovementSchema` defaults. Deeper controls appear in a collapsed **Jump response** group; the common setup does not acquire unused controls. Consume the saved row with `bindAuthoredMovement` to tune `movement.feel` live while retaining the game's callbacks and physics. These are physical movement controls; graph clips and transition choices remain authored separately.
- **Graph.** `animation.graph` is an `AnimGraph` (`@jgengine/core/anim/animGraph`): layers, each a state machine of `clip`, `blend1D` and `blend2D` states joined by transitions with `when` conditions, `trigger`s, crossfade `duration` and `exitTime`. It is plain data, so it saves, diffs and can be authored in the editor.
  - `locomotionGraph({ idle, walk, run, walkSpeed, runSpeed, fadeSec, oneShots })` builds the idle/walk/run blend plus one-shots; `states`/`oneShots` configs are converted through it. One-shot arrays retain every variant. `death` takes priority over simultaneous triggers and holds until the graph is reset; later hit/attack triggers cannot release it.
  - The same builder takes the directional set: `strafeLeft`/`strafeRight` add a `strafe` state blended on the `direction` parameter (`-1` fully left, `+1` fully right), `backward` adds a `backward` state behind `facing` (the dot of facing with motion), and every graph carries a `turn` state blended on `turnRate` that pivots on `walk` when no turn clip is authored. Each joins the idle/walk/run blend through a crossfaded transition with `strafeGate`/`strafeRelease`, `backwardGate`/`backwardRelease`, `turnGate`/`turnFull`/`turnSpeed` hysteresis, so a direction change blends instead of popping. A custom mover feeds `direction`, `facing` and `turnRate` under `ANIM_PARAMS_KEY`; a rig resolved with `animation: "auto"` gets the KayKit directional clips derived for it (`Running_Strafe_Left/Right`, `Walking_Backwards`). Supplying none of them leaves the speed-only tree exactly as before.
  - Upper-body actions (aim, reload, wave) go on a second layer with `mask: ["spine", "arm", ...]` bone prefixes, `additive` for recoil or breathing on top of the base.
  - Each layer owns its clip times and weights. Within one layer, two states using the same clip still resolve to one clip time; blending separate times of that same clip is unsupported.
  - `createAnimGraphRuntime(graph, { rng })` runs the same graph headless: `advance(dt, params, clips)`, `trigger`, `snapshot`/`restore`, `retune`. A clip state's `variants` are chosen once on entry; snapshots preserve that choice. Omit `rng` for the first variant. Runtime models use independent seeded visual streams keyed by instance id and imported clip names, so rendering or culling models cannot advance gameplay randomness. Remounting restarts that visual stream; gameplay-critical clip choices belong in a caller-owned headless runtime and its saved state. Outgoing clips keep advancing during crossfades. Test transitions with it before looking at pixels.
- **Authoring in the editor.** A placement's graph is inspected, previewed and stored from the editor's Animation workspace (see `jgengine-editor` reference, "Placement animation"). `animGraphFromConfig(config)` gives the graph any config plays, so tools and tests see what the shell plays. A missing/blank idle role leaves a named single clip active (or the bind pose if none is named); a missing/blank walk role holds the named idle at walking speeds. The inspector seeds supported semantic roles from the actual asset when enabling locomotion; imported assets without such roles need explicit choices. `validateAnimGraph(json)` rejects malformed saved graphs with located repairs, preserving valid graph data and transition order. `parseAnimGraph(json)` remains the permissive import path that drops malformed entries.
- **Static character placements.** Catalog placements validate saved `meta.animation` with the same strict value reader as dynamic characters and the inspector. A malformed override is omitted so the original catalog animation remains active; the saved document and placement stay unchanged. `resolveAuthoredObjectsWithDiagnostics(document)` returns the ordinary objects plus located repairs in one scan, and `<AuthoredObjects>` reports those repairs at runtime. Valid partial roles, empty maps, signed rates, ordered variants and extension fields keep their authored meaning. Asset clip and rig compatibility still belongs to the model diagnostics.
- **Dynamic character authoring.** Store animation on the game-selected marker at `meta.animation` through ordinary editor commands, then use `createAuthoredAnimationReader(() => getDocumentLiveSync()?.getDocument() ?? editorLayers, markerId, gameDefault)` (`@jgengine/core/world/authoredAnimation`). A game-owned `ModelConfig.animation` getter returns `read().animation`; the game chooses the actor-to-marker association, rig and defaults. The reader validates only after immutable document identity changes. Authored objects replace defaults rather than merging their roles, graph or combat triggers; explicit `"auto"`/`"none"` and in-progress empty/partial roles survive save/reload. Omitted or malformed overrides retain the exact game default, and `read().diagnostics` supplies document paths and repairs. Surface those diagnostics during inspection; asset clip and rig compatibility still use `diagnoseModelAnimation`.
- **Parameters.** The shell feeds the entity's smoothed ground speed as `speed`. For shared player movement it also reads physically resolved `grounded`, `verticalSpeed` and `crouched` through `playerMovementTelemetry(ctx, entityId)`; these values override conflicting blackboard extras. It derives the directional set from the entity's own motion each visual sample: `direction` (the motion's share along the character's right axis), `facing` (its share along the facing axis) and `turnRate` (its yaw change per second), from the entity's `position` and `rotationY`. Those override conflicting extras too, so a rig with the directional states strafes and backpedals with no game-side code. A step past the live `snapDistance` is a teleport, so the sample reports no direction and zero speed instead of inventing a lateral. Custom movers retain their authored extras under `ANIM_PARAMS_KEY` when telemetry is unavailable. `readModelAnimationParams(ctx, entityId, speed, out, step)` gives custom render hosts the same rule, clearing and reusing their own dictionary without writing game state; omit `step` to leave direction to the authored extras. The names are exported as `LOCOMOTION_SPEED_PARAM`, `LOCOMOTION_GROUNDED_PARAM`, `LOCOMOTION_VERTICAL_SPEED_PARAM`, `LOCOMOTION_CROUCHED_PARAM`, `LOCOMOTION_DIRECTION_PARAM`, `LOCOMOTION_FACING_PARAM` and `LOCOMOTION_TURN_RATE_PARAM`.
  - Jump clips remain explicit rig choices in the game's graph. A supported rig can enter its start clip on `!grounded && verticalSpeed > 0`, its air loop at the physical apex, and its landing clip on ground contact. Let an airborne or landing state return to takeoff when vertical speed becomes positive so a buffered re-jump is visible even if rendering misses the grounded frame. Use `verticalSpeed < 0` for falling from locomotion so unavailable movement data does not invent a fall. Verify those transitions against that rig and the game's physical motor; no automatic jump mapping or universal compatibility is supplied.
- **Placement discontinuities.** The shell uses the live simulation `snapDistance` to discard inferred gait speed when a character moves farther than that distance in three dimensions between visual samples. Retune it through `ctx.sim.retune({ snapDistance })` with the game's existing interpolation policy; `Infinity` permits continuous fast motion. Sampling resets the speed history while preserving graph time, triggers, variants and terminal states. The shared motor clears its retained state when its driven actor despawns. Raw position updates retain physical motor state; a reconstruction policy can call `forgetPlayerMovement` explicitly.
- **Upper-body action layers.** `actionLayer` (`@jgengine/core/anim/actionLayer`) builds one masked overlay from plain data: a resting clip plus a one-shot state per entry in `actions`, each joined by a consumed-trigger transition and returning on `exitTime`. `withActionLayers(graph, layers)` appends them to a locomotion graph, rejecting a duplicate layer id instead of shadowing the base.
  - `additive: true` makes it a recoil/breathing overlay on top of the base pose: the shell runs `AnimationUtils.makeClipAdditive`, which makes the rest clip's first frame the zero reference, so the rest state is held at `speed: 0` and contributes nothing while the base keeps animating. Without `additive` the layer replaces the masked bones.
  - `mask` is required and nonempty, because an overlay that names no bones would drive the whole rig. `UPPER_BODY_MASK` and `LOWER_BODY_MASK` (`@jgengine/core/anim/skeletonStandard`) are the masks for a standard rig, derived from the standard's own slots; `standardBoneMask(slots)` builds one from any slot list.
  - Aim, reload, wave and hit reactions are the same shape, so they are one builder rather than a hand-written state machine per game. `ctx.game.playEntityAnimation(id, name)` arms the trigger.
- **Triggers.** `ctx.game.playEntityAnimation(id, name)` arms a trigger; `hit` and `death` arm from their combat events.
- **Events.** `graph.events: [{ clip, atSec, name }]` emit `animation.event` when an influencing clip crosses that time. Full-cycle frames still fire; multiple missed cycles coalesce to one event per advance. Hook footstep audio, the damage frame of a swing or the magazine swap of a reload to the event, not to a timer.
- **Root motion.** `rootMotion: true` extracts local root-bone travel into `output.rootDelta` and plays the rig in place horizontally. `locomotionGraph({ ..., rootMotion: true })` (or `states.rootMotion`) marks the whole locomotion set — idle/walk/run, strafe, backward and turn — so one authored locomotion graph carries its own travel and the movement owner resolves it; a rig whose clips are authored in place leaves the flag off and reports no travel. The in-place flag remains active while a fading-out root-motion clip influences the pose, including interrupted fades and restored graph snapshots. The renderer never changes entity position: the movement/collision owner must consume the headless delta, transform it through the imported rig's parent/facing/scale with `takeRootMotion`, and resolve it through its controller. The bone's vertical motion remains animated. `createGraphPose.apply(output.clips, output.rootMotion)` gives editor/custom previews the same in-place policy. Existing graphs that relied on renderer-driven movement must migrate to a collision-authoritative host; no automatic vault or dodge motor is supplied. Extraction currently assumes the first bone has a matching `.position` track. Root deltas sample the current state's travel; they do not integrate outgoing travel or transition fade weights. Hosts needing collision-authoritative blended root travel must implement that policy explicitly; the in-place visual guarantee does not imply blended root-delta support.
- **Ragdoll on death.** `ragdollConfigFromPoses(map, poses, { scale })` (`@jgengine/core/anim/ragdollRig`) turns a mapped rig's world poses at the moment of death into a `RagdollConfig`: one box body per standard slot that has a shape from `HUMANOID_RAGDOLL_BODIES`, sized to the character, and one distance link per body to its nearest shaped ancestor, holding the bones' own separation. `handSlot.l/r` carry no body (prop sockets, not body parts); a slot the rig lacks is skipped and its children link to the nearest present ancestor.
  - `useRagdoll(scene, { map, world, active, height })` (`@jgengine/shell/render/useRagdoll`) arms it: the rig's world poses become the bodies, and every frame after, each bone's world position and aim come from its body. The engine's `PhysicsWorld` bodies carry translation only, so `ragdollBoneOrientations(rest, positions)` gives the skeleton its orientation by rotating each bone's death-pose direction-to-child onto the current one — that is what makes the result a body rather than a pile of boxes.
  - Give the ragdoll its own `PhysicsWorld` (the hook steps it), and stop the character's IK while it is active (`ModelConfig.ik` unset) or the two write the same joints. Deactivating removes the bodies and leaves the rig where the ragdoll put it. This is the death path for a rig whose death clip would otherwise freeze a pose in mid-air.
- **Diagnostics.** `diagnoseModelAnimation(scene, config, clips)` checks incomplete locomotion roles, missing active clip mappings, masks that match no used tracks and unsupported root-track assumptions. Explicit graphs take precedence over dormant role/single-clip settings, so inactive settings do not produce misleading warnings. Runtime and graph previews warn with the same repair guidance. An explicit invalid single clip retains the bind pose rather than playing an arbitrary first clip.
  - Inspector and Graph panels validate saved `meta.animation` with the same strict reader. A malformed override shows its document path and repair, leaves the saved data intact, and blocks ordinary playback edits and graph preview. Diagnostics remain visible without imported clip metadata. Explicit replacement discards the invalid override through an undoable marker edit; Custom derivation requires the rig's real clip list. Valid partial roles, signed playback values, variant order and extension fields survive unrelated edits.
- **Imported rig slots.** `ModelConfig.attachments[].slot` uses an exact runtime node name first, then a unique original imported name (`userData.name`). GLTFLoader can sanitize `handslot.r` to `handslotr`; authored slot references keep working without renaming animated bones.
  - `resolveRigNode(rig, name)` (`@jgengine/shell/render/rigNode`) returns the instance node or a missing/ambiguous-name diagnostic. Duplicate references need a unique runtime name or repaired imported names; no arbitrary target is selected.
  - Resolve on model load or slot changes and keep the node with its own instance. Attachments retain their local offsets under the animated slot and the model's placement, facing and scale; cleanup removes only that instance's attachment.
  - This resolves node identity; it does not retarget animations or make different rigs share attachment proportions.
- **Foot IK.** `ModelConfig.ik` runs after the mixer each frame.
  - `ik: "auto"` finds thigh → shin → foot chains by bone name. Pass `{ feet: [{ root, mid, tip }] }` when the rig's names are unusual, and `feet: []` to keep only `lookAt`.
  - `lookAt: { bone }` turns a bone (the head) toward the camera. `lookAt.target` aims it at an entity (`{ entity: "mark" }`, offset by `entityEyeHeight`, default 1.5) or a fixed world point (`{ point: [x, y, z] }`) instead; `maxYaw`/`maxPitch` bound the turn in radians and `weight` (default 1) scales how much of the solved turn reaches the bone. An entity target that is not spawned keeps the last known direction, so a despawn does not snap the head back to the camera. `lookAt` with `feet: []` is look-at on its own.
  - `@jgengine/core/anim/ikSolver`'s `lookAt({ from, target, up, maxYaw, maxPitch })` is the pure solver, for a custom host or a non-head chain (a turret, a neck).
  - `ModelIkConfig.lookAt` is also the place a game points a character at a thing; nothing else in the shell reads a look-at target.
  - Explicit joint, `pelvis` and `lookAt.bone` references use exact runtime names first, then the same unique original imported-name lookup as attachments. A name such as `upperleg.r` can resolve after GLTFLoader sanitizes it to `upperlegr`; this preserves node identity and does not supply rig retargeting.
  - Each named joint must resolve uniquely and descend from the preceding joint; intermediate helper transforms are allowed. Missing, ambiguous, disconnected or overlapping chains produce a resolution warning and are skipped. Missing `pelvis` and `lookAt.bone` names also warn. Automatic pelvis correction uses the resolved legs' common ancestor; an explicit `pelvis` retains the authored selection. Auto detection is a naming convention, not compatibility with every rig.
  - Each foot keeps its animated swing and moves by its ground's height above or below the model origin. The pelvis drops so the lower leg can reach, soles never end under the ground, and planted feet tilt to the ground normal (`alignToGround`, `0..1`). The correction fades out while the feet are clear of the ground.
  - `maxAdjust` caps the correction as a fraction of leg length (default `0.4`). Probes hit terrain and blocking physical objects, never the model's own entity.
  - Disabling or replacing IK clears its previous corrections and resets smoothing; unmount restores untouched joint poses while preserving newer animation writes. This keeps editor play/adjust loops from leaving an offset pelvis after IK is removed.
  - The pure pieces are `placeFeet` and `inferLegChains` (`@jgengine/core/anim/footPlacement`) and `solveTwoBone`/`solveFabrik`/`lookAt` (`@jgengine/core/anim/ikSolver`), for a custom host or a non-leg chain (a hand on a ledge, a tail).
- **Crowds.** `<SkinnedInstances url instances clips once fps modelScale />` (`@jgengine/shell/render/SkinnedInstances`) draws hundreds of animated copies of one rig in one instanced draw per material. Each `CrowdInstance` has `position`, `rotationY`, `scale`, `clip`, `timeOffset` and `speed`.
  - Every clip is baked once to a bone-matrix texture (layout from `planBoneTexture`, `@jgengine/core/anim/boneTexture`) and sampled on the GPU. Clips in `once` hold their last pose. A member changes clip when the game passes a new `instances` array.
  - Only skinned meshes are baked. Static props parented to bones (a weapon in a hand slot, a hat) are dropped, and a mesh with several materials keeps its first.
  - Crowd members have no graph, IK or attachments. Promote the few near the camera or near the player to entity models.
  - `bakeSkinnedCrowd` and `patchCrowdMaterial` are exported for a custom renderer.

## Symptom → knob

| Symptom | Knob |
| --- | --- |
| Feet slide at walk or run speed | `walkSpeed`/`runSpeed` to the clip's authored stride speed, or blend1D `at` points |
| Character slides sideways or backwards with no clip change | `strafeLeft`/`strafeRight`/`backward` states, and feed `direction`/`facing` under `ANIM_PARAMS_KEY` |
| Character pivots with planted feet | a `turn` clip, or accept the `walk` pivot step; tune `turnGate`/`turnFull` |
| A clip from one CC0 pack will not play on another rig | map the rig with `mapRigToStandard` and rename the clip with `retargetTracks`; check `missing` before blaming the clip |
| The head never turns, or turns the wrong way | `ik.lookAt.bone`, and `lookAt.target` when it should track an entity or a point rather than the camera; bound it with `maxYaw`/`maxPitch` |
| A hit reaction replaces the walk instead of layering on it | `actionLayer({ additive: true, mask: UPPER_BODY_MASK, ... })` and `withActionLayers` |
| A corpse freezes in mid-air, or a death clip holds a standing pose | `useRagdoll` on the death event, with `ik` unset while it is active |
| A locomotion set's travel does not match the feet | `locomotionGraph({ rootMotion: true })`, then resolve `rootDelta` through the collision owner |
| Pop between states | transition `duration`; `exitTime` on one-shots returning to locomotion |
| Feet float or sink on slopes and steps | `ik: "auto"` |
| Knees buckle on steep ground | lower `ik.maxAdjust` |
| Feet tilt wrongly on rubble or props | lower `ik.alignToGround` |
| Attack damage lands before the swing | `graph.events` on the hit frame instead of a timer |
| Lunge distance doesn't match the feet | extract `rootMotion: true` travel and resolve it through the collision owner |
| Hundreds of animated characters cost thousands of draw calls | `SkinnedInstances` for the background crowd |
