Animation
Playing a clip
The durable way is the ClipPlayer component — attach it to the skinned body and it plays one clip through the AnimGraph:
entity.find("hero").component.add("ClipPlayer", {
clip = asset.resolve("A_Walk_F_Masc", "animation"),
looping = true,
})
clip is an AssetRef<animation> (survives renames/moves); looping, speed, and
playing control playback. ClipPlayer waits for the body's skeleton to hydrate, so
it works on a character spawned from a bundle where the rigged mesh loads a few
frames later.
Testing a clip quickly
To eyeball a clip without authoring a component, use the animation toolbox:
animation.play("A_Walk_F_Masc", "hero") -- play once, auto-cleanup
animation.play("Idle", "hero", { offset = "5s" }) -- wait 5s, then play
animation.play("Wave", "hero", { loop = true, duration = 8 }) -- loop for 8s
animation.play resolves the clip by identity or name and the entity by id or name,
finds the SkinnedModel on it (or under it — the imported-character layout puts the
rigged mesh on a child), plays through the AnimGraph, and tears everything down when
the window ends. On a body that already animates it overrides for the window and the
body's own animation resumes untouched. Playback runs in play mode, like any
component-driven animation.
Where animation comes from
Animated content is assets: rigged models with embedded clips, and standalone
animation clips you can retarget. Survey what exists with asset.list("animation"),
asset.list("bundle"), and the shared library, or generate humanoid animations with
the animation service (the generating-assets-and-content guide). asset.inspect on a rigged
model shows its clip names.
How a joint blends
A skinned vertex is placed by combining the joints that influence it, and there are two ways to combine them. The default averages the joint matrices — cheap, and what every rig gets unless it says otherwise. Where a joint twists far (a wrist rolling over, a shoulder, a spine), that average stops being a rotation and the limb pinches toward its own axis: the candy-wrapper. Switching that body to dual-quaternion skinning averages the joints' rigid motions instead and renormalises, so the cross-section survives the twist:
entity.find("hero").component.get("SkinnedModel").skinning = "dualQuaternion"
It is per body, so a character that twists hard can carry it while the rest of the cast stays on the cheaper default, and a rig at rest looks the same either way.
Checking what is animating, and why a body is not
animation.observe() reports every body the engine holds animation state for.
It reads the pose the engine measured landing on each armature, so it answers
whether a body is moving rather than whether something asked it to:
for _, body in animation.observe().bodies do
print(body.entity, body.animating, body.reason, body.matched .. "/" .. body.total)
end
animating is the engine's verdict. A ClipPlayer whose playing field is
true on a body whose bones never move reads animating = false here, with
reason naming the cause from a closed set: deactivated, noRiggedSkeleton,
noGraph, clipUnreadable, noOutputNode, retargetMatchedNoRoles,
stopped, finished, paused, poseNotApplied, poseUnchanged.
An editor sits paused until you press play, and a paused engine steps no
animator. Every body reads animating = false there, and reason separates the
ones that will move on resume — paused — from the ones that will not: a clip
that reaches none of the rig's bones still reports retargetMatchedNoRoles
while paused, because that is what is still wrong when the world starts again.
The clips, playheads and coverage a body was last driven with stay readable
across the pause.
Starting from one character
animation.whyStill is the one-line form, and it accepts a character root —
the rig lives on a skinned child, and the call resolves down to it:
local why, detail = animation.whyStill("hero")
if why then print("hero is not animating:", why, detail) end
When more than one animator is bound
body.driver is the animator the engine measured posing the body. A body can
carry two — a script animator and an AnimGraph component — and then
body.otherDrivers names the ones the report is not built from:
local body = animation.body("hero")
print(body.driver, table.concat(body.otherDrivers, ", "))
Each animator's poses are measured on their own, so the one whose pose the engine saw changing is the one the body's clips, playheads and coverage describe.
Is the clip reaching this rig
A clip is retargeted onto the body's rig when it binds, and the coverage that bake produced is the number that separates a working clip from a dead one:
local matched, total = animation.coverage("hero") -- 41, 50
for _, clip in animation.clips("hero") do
print(clip.name, clip.time, clip.matched .. "/" .. clip.total, #clip.unmatched)
end
A humanoid clip on a humanoid rig reaches most of the bones. A clip from an
unrelated rig reaches none — 0/50 — and the body stands in its rest pose while
the playhead advances exactly as it does on a working body. clip.unmatched
names the bones the clip leaves at rest.
Measure the pose, not the playhead
body.pose is what the engine wrote onto the bones:
local pose = animation.body("hero").pose
print(pose.bonesWritten .. "/" .. pose.bonesRequested, pose.revision, pose.sinceChangedMs)
applies counts poses that reached the armature; revision counts the ones
whose values differed from the pose before. A body being animated advances both.
A body held in one pose advances applies alone, and sinceChangedMs grows.
pose.refusal names why a pose never landed at all — deactivated,
unsupportedStride, targetMissing, noSkeleton, noArmatureRoot,
armatureDrivenElsewhere.
armatureDrivenElsewhere is the one to know when two animators share a body: an
AnimGraph component and a legacy animation player each rebuild the joint
matrices the renderer draws from their own graph, so a pose another animator
writes onto the same armature's bones is replaced before a frame is drawn from
it. The body then reads the animator that owns its skinning as its driver, and
names the other in otherDrivers.
From the shell, and on screen
tools.use("animation", "observe") condenses the same report to one line per
body, and /zero/runtime/animation serves it as a file:
vfs.read("/zero/runtime/animation") -- the whole observation
vfs.read("/zero/runtime/animation/bodies/" .. body.id) -- one body
The AnimDebug component in the character-controller package renders the same
report as an on-screen HUD.
Going further
For blending, crossfades, and state machines, build on the AnimGraph directly
(@builtin::systems.anim.AnimGraph — layoutForEntity, Clip, Mixer,
BlendSpace2D, Layer). For full character movement — locomotion blending, turning,
jumps — the built-in character controller wires clips to input and physics; inspect it
rather than rebuilding that from clips.