DEMIENGINEDOCUMENTATION Everything is free
Animation & audio · Game systems

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 states is 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. time is seconds into the authored state duration; name is emitted in an animation_event payload (see Events & timers). Give timed states a positive duration to make loop and finished timing 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_spaces names 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 layers is the layer name used by set_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, or less. The latter five use parameter; greater and less also use threshold. finished checks 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
CallTakesReturns
Animation.set_number(id, name, value)Entity, parameter name, numbertrue when applied
Animation.set_bool(id, name, value)Entity, parameter name, booleantrue when applied
Animation.trigger(id, name)Entity, trigger nametrue when armed — a one-shot condition until consumed
Animation.play(id, state)Entity, state nametrue when entered immediately
Animation.state(id)EntityCurrent state name string; "" when unavailable
Animation.set_speed(id, rate)Entity, playback multipliertrue when applied
Animation.normalized_time(id)EntityNumber 0…1: position within the current clip
Animation.transition(id)EntityTransition info table: from, to, progress (0…1), active boolean
Animation.set_layer_weight(id, layer, weight)Entity, layer name, 0…1true when applied
Animation.set_root_motion(id, on)Entity, booleantrue 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