Animation: a first character that moves
The way in to animation in this engine: import a model, give it an animator, play its clips, switch between them, blend, loop, react to events, and rig a model that came with no skeleton.
The way in to animation in this engine: import a model, give it an animator, play its clips, switch between them, blend, loop, react to events, and rig a model that came with no skeleton. Every example here is run by a test (crates/editor/tests/animation_guide.rs reads them out of this file), so what you copy works. The full reference is docs/API.md ("Animation — ..." sections); this page is the order to learn it in.
The examples use the mannequin from the third-person recipe, fixtures/recipes/third-person-character/scenes/models/mannequin.gltf: a stand-in character on a two-joint rig with the clips Idle, Run, Jump, Fall and Land. Copy it into your project's scenes/models/, or use your own model and its clip names.
1. Import a model
In the editor, Import on the toolbar (or drop the file on the window); an agent calls asset.import with the file's path. Either way the file is copied beside the scene and an asset entry is written that says what the model can do:
<!-- guide: asset-entry -->
"mannequin": { "kind": "glb", "path": "assets/mannequin.gltf", "rig": "custom",
"clips": ["Idle", "Run", "Jump", "Fall", "Land"],
"clipDurations": { "Idle": 2, "Run": 0.6, "Jump": 0.5, "Fall": 0.8, "Land": 0.4 },
"skeleton": { "name": "rig", "joints": 2, "bones": ["hips", "chest"] } }
rigiscustomfor a model with a skin (a skeleton its vertices follow) andnonefor one without. Only a skinned model, or one with morph targets, has anything for an animator to move. A model with no skeleton can be given one: see Rig a model that has none.clipsare the names you write in the animator. They are the file's own animation names, spelt exactly.- The import also checks the model stands up +Y and faces -Z, the engine's forward. If it does not, it says so (
rest-pose-orientation) with the fix: the editor's Import sets the asset'sforwardfor a character authored facing +Z (glTF's own front, which most are); an agent sets"forward"from the warning'sfix.forward. Without it the character runs backwards. - A model 100 times too big is usually in centimetres: import it with a unit scale of 0.01.
2. Make it move with no script
Give the entity an animator whose states are named after what the character controller does: idle, moving, jump and land. The controller raises those four events as it starts and stops moving, leaves the ground and lands, and the engine switches the animator to the state of the same name when it has one. Nothing else to write:
<!-- guide: zero-script -->
"components": [
{ "type": "mesh", "asset": "mannequin" },
{ "type": "collider", "shape": "capsule", "radius": 0.35, "height": 1.8 },
{ "type": "rigidbody", "kind": "kinematic" },
{ "type": "controller", "kind": "player", "speed": 6, "jump": 1.5 },
{ "type": "animator", "asset": "mannequin", "initial": "idle",
"states": [
{ "name": "idle", "clip": "Idle", "loop": true, "transitions": [] },
{ "name": "moving", "clip": "Run", "loop": true, "transitions": [] },
{ "name": "jump", "clip": "Jump", "loop": false, "transitions": [] },
{ "name": "land", "clip": "Land", "loop": false, "transitions": [], "once": { "then": "idle" } } ] } ]
Press Play and push the stick (or WASD): moving plays Run, and letting go goes back to idle. A controller needs the scene's player spawn, which a new project's scene already has.
- A state plays one
clip(or one blend tree, below).loop: truerepeats it;loop: falseplays it once and holds the last frame. initialis the state it starts in.oncemakes a state a one-shot that hands over by itself when its clip ends:landplaysLandonce and goes back toidle.- The switch between states is a crossfade, 0.2 s unless a transition says otherwise (section 4).
3. Drive it from your own behaviour
Most games decide the state themselves. A behaviour gets the animator as ctx.animator: set(state) switches to a state, play(name) plays a state or a clip once and then hands back, and state reads the one playing. Setting the state every tick is what takes over from the controller's own switching in section 2 (it only switches where your script did not this tick).
<!-- guide: own-behaviour-scene -->
"components": [
{ "type": "mesh", "asset": "mannequin" },
{ "type": "collider", "shape": "capsule", "radius": 0.35, "height": 1.8 },
{ "type": "rigidbody", "kind": "kinematic" },
{ "type": "controller", "kind": "player", "speed": 6, "jump": 1.5 },
{ "type": "animator", "asset": "mannequin", "initial": "idle",
"states": [
{ "name": "idle", "clip": "Idle", "loop": true, "transitions": [] },
{ "name": "run", "clip": "Run", "loop": true, "transitions": [] } ] },
{ "type": "script", "module": "behaviors/hero" } ]
scenes/behaviors/hero.ts:
<!-- guide: own-behaviour -->
import type { Behavior } from "@argus/api"
const hero: Behavior = {
update(ctx) {
const v = ctx.controller!.velocity
const speed = Math.hypot(v[0], v[2])
ctx.animator!.set(speed > 0.5 ? "run" : "idle")
// Once, then back to run or idle. `interrupt: []`: the set() above cannot cut it short.
if (ctx.input!.pressed("jump")) ctx.animator!.play("Jump", { interrupt: [] })
},
}
export default hero
play takes options: then (the state to go to afterwards), blend (the crossfade in), interrupt (the states that may cut it short; any by default, so a set every tick would end it at once, and [] lets nothing) and event (the bus event raised when it ends, animation-end by default; see section 6).
4. Transitions, and what fires them
A transition on a state says "on this event, go to that state, crossfading over this many seconds". Transitions are run by the built-in animator behaviour, so the entity needs it as a script component. It hears the controller's four events by itself; any other event name goes in its events prop, and then any behaviour can fire it with ctx.events.emit:
<!-- guide: transitions -->
"components": [
{ "type": "mesh", "asset": "mannequin" },
{ "type": "animator", "asset": "mannequin", "initial": "idle",
"states": [
{ "name": "idle", "clip": "Idle", "loop": true, "transitions": [{ "on": "alarm", "to": "panic", "blend": 0.3 }] },
{ "name": "panic", "clip": "Run", "loop": true, "transitions": [{ "on": "all-clear", "to": "idle", "blend": 0.5 }] } ] },
{ "type": "script", "module": "animator", "props": { "events": ["alarm", "all-clear"] } } ]
<!-- guide: emit -->
ctx.events.emit("alarm") // from any behaviour: every animator listening for "alarm" takes it
The rules, all of them:
onis the name of a bus event. Theanimatorbehaviour hearsmoving,idle,jumpandlandwith no prop, and other names only when they are in itseventslist.- Without the
animatorscript component no transition is ever taken. The scene still loads and the state just sits there; the load saysanimator-transition-unheard, naming the events and the line to add. blendis the crossfade time into the target, in seconds.ctx.animator.setuses it too when a transition reaches the state, so a transition kept only for its blend can say"on": "".- A transition is taken from the state playing now only.
tomust name a state (animator-transition-undefinedotherwise, and the scene is refused).
So there are three ways a state changes, and they combine: the controller's events onto states of the same name (section 2), transitions through the animator behaviour, and set/play from your own behaviour, which wins on the tick it is called.
5. Blend by a number: walk to run
A state can play a blend tree instead of one clip: it mixes clips by a parameter. Here the parameter is bound to the controller's speed, so the engine writes it every tick and there is no script at all:
<!-- guide: blend-tree -->
"components": [
{ "type": "mesh", "asset": "mannequin" },
{ "type": "collider", "shape": "capsule", "radius": 0.35, "height": 1.8 },
{ "type": "rigidbody", "kind": "kinematic" },
{ "type": "controller", "kind": "player", "speed": 6, "jump": 1.5 },
{ "type": "animator", "asset": "mannequin", "initial": "locomotion",
"parameters": [{ "name": "speed", "value": 0, "source": "speed", "damp": 0.15 }],
"states": [
{ "name": "locomotion", "loop": true, "transitions": [],
"blend": { "kind": "linear", "parameters": ["speed"], "points": [
{ "clip": "Idle", "at": [0] }, { "clip": "Run", "at": [6] } ] } } ] } ]
parametersdeclares every number a tree reads.sourcebinds one to a fact the controller measures (speed,strafeX,strafeY,verticalSpeed,grounded, and theinput…ones); leavesourceoff and a behaviour writes it withctx.animator.param("speed", 3).dampeases it over that many seconds so the pose does not pop.pointsput each clip at a value; between two points the two clips are mixed, past the ends the end clip plays. Clips inside a tree stay in step.- With a real walk, jog and sprint, put each point at the speed its clip was authored at (
GET /feel?section=clipsreads it); the third-person recipe's README shows the tree.
6. Clips that loop, play once, and say when
loop: true repeats a clip; loop: false plays it once and holds. A one-shot (once on the state, or ctx.animator.play) plays once and hands over by itself, and raises a bus event when it ends. Events mark moments inside a clip: at is a fraction of the clip, 0 to 1, and each is raised on the tick the clip passes it, with { entity, clip, at } as its data:
<!-- guide: events-scene -->
"components": [
{ "type": "mesh", "asset": "mannequin" },
{ "type": "animator", "asset": "mannequin", "initial": "run",
"states": [
{ "name": "run", "clip": "Run", "loop": true, "transitions": [] },
{ "name": "trip", "clip": "Fall", "loop": false, "transitions": [],
"once": { "then": "run", "event": "got-up" } } ],
"events": [
{ "clip": "Run", "at": 0.25, "event": "footstep" },
{ "clip": "Run", "at": 0.75, "event": "footstep" } ] },
{ "type": "script", "module": "behaviors/runner" } ]
scenes/behaviors/runner.ts:
<!-- guide: events -->
import type { Behavior } from "@argus/api"
const runner: Behavior = {
init(ctx) {
ctx.state.steps = 0
},
onEvent(ctx, ev) {
const data = ev.data as { entity?: string }
if (data?.entity !== ctx.entity.id) return // one listener hears every character's events
if (ev.name === "footstep") {
ctx.state.steps = (ctx.state.steps as number) + 1
if (ctx.state.steps === 6) ctx.animator!.play("trip") // the one-shot above
}
if (ev.name === "got-up") ctx.log("back on its feet")
},
}
export default runner
- Events sit on the animator, not on a state: a clip's footsteps are the same whichever state plays it, inside a blend tree too (where the heaviest clip speaks).
- An event with
untilis a window that opens and closes (data.phasebegin/end), for an attack's active frames;ctx.animator.window(name)says whether it is open. Seedocs/API.md, "Windows".
Check a clip before you use it. The asset gate's motion command reads a skinned GLB's clips the way the engine plays them and judges each from its motion alone, before anyone has to watch it. It reports one JSON line: pass, the rig (height, feet, whether it is a canonical humanoid) and, per clip, its numbers and checks:
motion-loop-seam: a clip that must loop (--loop, or--intent idle,walkorrun) meets itself at the wrap, by the same test as the runtime'sanimator-loop-seamwarning.motion-foot-slide: a planted foot stays put (or, in a clip that walks in place, moves with the floor).motion-spike: no joint pops out and back in one frame or snaps by a large angle, and the root does not jump.motion-bone-coverage: a walk or run on the canonical humanoid animates every bone the built-in clips need.motion-bad-keys,motion-root-drift(an idle that wanders off),motion-speed(with--speed, the clip's ground speed against the speed you tagged),motion-lengthandmotion-size; warnings for the hips' height, a foot under the floor and a rig that is not a person's height in metres.
Lengths are judged as a share of the rig's height, so a file in centimetres is held to the same standard as one in metres. Every limit is provisional until real clips have set it. Each reject ends with a Next: saying what to fix.
7. Rig a model that has none
Characters from an AI model generator or a scan are usually one rigid mesh: no skin, nothing to animate. Tick Give it a simple body rig in the Import modal (an agent passes bodyRig: true to asset.import). The copy gets five joints up its middle and nine body clips: Idle, LeanLeft, LeanRight, Punch, Throw, ThrowBack, PointLeft, PointRight and Cheer. Your file is not changed. Then animate it like any other:
<!-- guide: body-rig -->
"components": [
{ "type": "mesh", "asset": "statue" },
{ "type": "animator", "asset": "statue", "initial": "idle",
"states": [
{ "name": "idle", "clip": "Idle", "loop": true, "transitions": [] },
{ "name": "cheer", "clip": "Cheer", "loop": false, "transitions": [], "once": { "then": "idle" } } ] } ]
A body has no arms to wave, so the clips are leans, twists and hops. A model that already has a skeleton is refused the option, and so is one with compressed geometry.
8. Models that move parts, not a skeleton
A door on a hinge, a fan, a chest lid: many models are animated by moving their parts (glTF nodes) rather than a skeleton. They play through the same animator. Import gives such a model an animator that plays its clips, and its asset entry says "rig": "none" with the clips it has. Each part rides its node, and states, one-shots, events and speed work as in the sections above, with the parts' node names standing in for bones (blend trees and layers too: docs/API.md, "Node animation"). Here a door imported as door, whose clip Open swings it on its hinge; the state plays it once and holds it open:
<!-- guide: node-animation -->
"components": [
{ "type": "mesh", "asset": "door" },
{ "type": "animator", "asset": "door", "initial": "open",
"states": [{ "name": "open", "clip": "Open", "loop": false, "transitions": [] }] } ]
- Only an entity with the animator draws the parts moving. Without one the model stands still, and colliders, picking and the navmesh always read it at rest.
- A skinned character can carry parts too. A part outside the skin that a clip moves, or that carries a mesh of its own with no skin (a sword in a hand, a lid on a backpack), rides the skeleton and plays its own clips.
- A clip that turns only the node a skinned mesh hangs on still moves nothing: a skinned mesh follows its bones, not its node. The import says so (
node-animation); skin those parts in your modelling tool, give them a mesh of their own, or move the entity from a behaviour.
9. Common mistakes
| You did | What you see | The fix |
|---|---|---|
Transitions with no animator script component, or an event missing from its events | the state never changes; animator-transition-unheard at load | add {"type": "script", "module": "animator", "props": {"events": [...]}}, as the message says |
A clip name spelt differently from the asset's clips | the model holds its rest pose; animator-clip-missing at load, animator-invalid from the critic | copy the name from the asset entry's clips |
ctx.animator.set("Run") with the clip's name instead of the state's | nothing changes; animator-unknown at runtime, counted on GET /feel | set takes a state name; play takes a state or a clip |
ctx.animator.param("sped", 1) | throws AnimatorParamError (animator-param-unknown) | declare it in parameters, spelt the same |
An event at: 12 (a frame, or seconds) | the scene is refused: animator-event-invalid | at is a fraction 0 to 1; for a frame number write frame |
loop: true on a one-shot | the scene is refused: animator-once-invalid | a one-shot has loop: false |
| An animator on a model with no skin | it never moves; has no skin; nothing to animate at load | import it again with the body rig (section 7) |
| A character that runs backwards or lies down | rest-pose-orientation at import | set the asset's forward (or the import's up axis) from the warning's fix |