States, parameters, transitions, layers, and events.
State machines
Characters and props play animations through a state machine: a small graph of named states (idle, run, attack) with rules for moving between them. The demi.animation module drives that graph from scripts.
What a state machine is
A state machine is a set of states, each pointing at a clip to play, plus transitions that say when to switch. You do not scrub frames by hand: you set parameters (speed, a trigger), and the machine picks the state. Layer and blend-space data can be authored, with the current presentation limits described below.
States
A state names a clip and can carry timed markers. In a 3D model the clip is model_clip_name; in a 2D sprite sheet it is sprite_clip.
{
"states": {
"run": {
"model_clip_name": "Run_Loop",
"duration": 0.8,
"events": [
{ "time": 0.2, "name": "footstep_left" },
{ "time": 0.7, "name": "footstep_right" }
]
},
"idle": { "model_clip_name": "Idle_Loop", "duration": 1.0 },
"skyforge_slam": { "model_clip_name": "Slam", "duration": 0.6, "loop": false }
}
}
- The key in
statesis the stable state name used by transitions and Lua. model_clip_name/sprite_clip— which clip this state plays (one or the other, depending on the entity).events— array of{time, name}markers.timeis seconds into the authored state duration;nameis emitted in ananimation_eventpayload (see Events & timers). Give timed states a positivedurationto make loop andfinishedtiming meaningful.
Markers fire whether or not anyone listens. Subscribe to animation_event and inspect its entity_id, state, and name fields; the marker name is payload data, not the event channel.
Blend spaces
A blend space samples named states by one or two numeric parameters. Its key must match the active state name; the example samples idle and run while locomotion is active. The machine records sample weights, but current playback chooses the highest-weight state; it does not interpolate their visual poses.
{
"states": {
"locomotion": { "model_clip_name": "Idle_Loop", "duration": 1.0 },
"idle": { "model_clip_name": "Idle_Loop", "duration": 1.0 },
"run": { "model_clip_name": "Run_Loop", "duration": 0.8 }
},
"initial_state": "locomotion",
"parameters": { "speed": 0 },
"blend_spaces": {
"locomotion": {
"parameter_x": "speed",
"points": [
{ "x": 0, "state": "idle" },
{ "x": 4, "state": "run" }
]
}
}
}
- The key in
blend_spacesnames the state on which the space is sampled. parameter_x— the parameter name sampled to pick the mix.points—{x, state}samples referencing declared states. The current parameter value determines the neighboring sample weights; the largest weight wins for playback.
Set the parameter with set_number (below). The chosen clip can change as the largest sample weight changes.
Layers
Layer data describes a secondary state, weight, additive mode, and bone mask. The runtime stores these on the animation player, and set_layer_weight changes the stored weight. The current 3D renderer samples one base clip and does not combine layer poses, so this does not yet produce a walk-and-cast upper-body animation.
{
"layers": {
"upper": {
"state": "cast",
"weight": 0.8,
"additive": false,
"mask": ["spine", "chest", "arm_l", "arm_r"]
}
}
}
- The key in
layersis the layer name used byset_layer_weight. state— which state plays on this layer.weight— how much the layer contributes (0…1).additive— authored intent for additive layering; visual pose composition is not yet implemented.mask— authored bone names for a layer; the current renderer does not apply the mask.
Transitions
A transition moves from one state to another when its condition holds. blend_duration records transition progress for Lua and preview state, but the current renderer switches the played clip without a visual cross-fade.
{
"transitions": {
"start_run": { "from": "idle", "to": "run", "condition": "greater",
"parameter": "speed", "threshold": 0.1, "blend_duration": 0.15 },
"stop_run": { "from": "run", "to": "idle", "condition": "less",
"parameter": "speed", "threshold": 0.1, "blend_duration": 0.2 },
"start_slam": { "from": "*", "to": "skyforge_slam", "condition": "trigger",
"parameter": "slam" },
"end_slam": { "from": "skyforge_slam", "to": "idle",
"condition": "finished", "blend_duration": 0.05 }
}
}
from/to— state names.condition—always,finished,trigger,true,false,greater, orless. The latter five useparameter;greaterandlessalso usethreshold.finishedchecks the state's positive authored duration.blend_duration— seconds of tracked transition progress; visual pose cross-fading is not yet implemented.
Without finished, a one-shot attack never falls back to idle on its own. Pair one-shot states with a finished transition out. The target and source states must both be declared in the same machine.
Driving the machine from Lua
local Animation = require("demi.animation")
Animation.set_number(self.id, "speed", 2.5) -- numeric parameter
Animation.set_bool(self.id, "grounded", true) -- boolean parameter
Animation.trigger(self.id, "jump") -- one-shot trigger
Animation.play(self.id, "skyforge_slam") -- force a state
print(Animation.state(self.id)) -- current state name, or ""
Animation.set_speed(self.id, 1.25) -- playback rate multiplier
local t = Animation.normalized_time(self.id) -- 0…1 position in the current clip
| Call | Takes | Returns |
|---|---|---|
Animation.set_number(id, name, value) | Entity, parameter name, number | true when applied |
Animation.set_bool(id, name, value) | Entity, parameter name, boolean | true when applied |
Animation.trigger(id, name) | Entity, trigger name | true when armed — a one-shot condition until consumed |
Animation.play(id, state) | Entity, state name | true when entered immediately |
Animation.state(id) | Entity | Current state name string; "" when unavailable |
Animation.set_speed(id, rate) | Entity, playback multiplier | true when applied |
Animation.normalized_time(id) | Entity | Number 0…1: position within the current clip |
Animation.transition(id) | Entity | Transition info table: from, to, progress (0…1), active boolean |
Animation.set_layer_weight(id, layer, weight) | Entity, layer name, 0…1 | true when applied |
Animation.set_root_motion(id, on) | Entity, boolean | true when applied — uses the current state's authored root_motion_track |
set_number and set_bool create or update a parameter on an existing machine; trigger arms a named trigger even if no transition uses it. Both return false when the entity has no machine. Animation.state returns an empty string for an entity with no machine.
local Animation = require("demi.animation")
local Input = require("demi.input")
function SkyforgeSlam:on_update(dt)
self.speed = self.speed or 0
Animation.set_number(self.id, "speed", self.speed)
if Input.pressed("attack") then
Animation.trigger(self.id, "slam") -- needs a transition with condition "trigger", parameter "slam"
end
end
set_number feeds blend spaces and comparison conditions; trigger arms a one-shot that a matching transition consumes; play bypasses conditions when you need a specific state right now. Prefer parameters and transitions — they keep timing in the authored graph instead of scattered through scripts.
Next steps
- Events & timers — receiving clip markers and collision toggles.
- Script lifecycle — where to set parameters every frame.
- Audio mixing — playing the sounds those markers trigger.