Skip to content

Animation Controller

A per-character animation controller layered over AnimationState. Where node.animations[name] hand-drives individual clips, an AnimController owns and drives one AnimationState for the character and adds typed parameters, clip-phase feedback (normalized time, tagged sections, a latch-and-refire END), behaviour hooks, and read-only root motion.

The engine auto-ticks every attached controller each frame (after animation-event dispatch, before Game.update(dt)), so you usually never call tick yourself.

var ctrl = node.attachController("controllers/biped.toml")

node.attachController(path) creates a controller bound to the node (its armature and the clips from the node’s .blend), loads the graph from the TOML asset at path, and returns an AnimController. The controller is owned by the engine and auto-ticked; keep the returned object to drive params and read phase. Editing the TOML file at runtime hot-reloads the graph (a parse/validation error is logged and the previous graph is kept).

Parameters are the controller’s blend inputs and (with the graph) transition-guard variables.

MethodDescription
setFloat(key, value) / getFloat(key)Float parameter
setInt(key, value) / getInt(key)Int parameter
setBool(key, value) / getBool(key)Bool parameter
setTrigger(key)One-shot trigger; auto-resets after it fires a transition
ctrl.setFloat("speed", 3.2)
ctrl.setBool("grounded", true)
ctrl.setTrigger("attack")

play(name) bypasses the graph for one-off/scripted playback. name is either a named transition (from the [transitions] table — crossfades to its clip with its fade/speed/start_time) or a bare clip action name (a one-off, non-looping clip). Named transitions take precedence.

ctrl.play("to_attack") // a named transition value-object
ctrl.play("Wave") // a bare clip action

An Action is a transient overlay that plays on a masked layer over the current State without exiting it (attack, wave, hit-react), authored in [actions]. action(name) triggers it; it overlays the action’s clip on its layer, completes on the clip’s latch-and-refire END (the layer then stops), and interrupts any action already on that layer. Returns false if the action is unknown or blocked by a Context.

ctrl.action("attack") // overlay the attack Action; locomotion underneath is untouched

A Context ([contexts]) is a modal gate over the params: a State or Action it lists is permitted only while the context’s when is true; a name in no context is always permitted. A context-blocked State transition holds (its trigger is preserved for when the context opens); a blocked action(...) is a no-op. Behaviour hooks (onEnter/onUpdate/onExit/onEnd) fire for Actions by name, the same as for States.

A layer is a masked pose overlay composited over the controller’s single state graph — not a state machine of its own. Each layer (authored in [[layers]]) has a blend mode (override or additive), a bone mask (empty = the whole body; unmasked bones fall through to the base pose), and a runtime weight in 0..1. Layer 0 is the implicit full-body base and is always weight 1. What a layer plays comes from the Actions bound to it.

ctrl.setLayerWeight(layer, weight) · ctrl.layerWeight(layer)

Section titled “ctrl.setLayerWeight(layer, weight) · ctrl.layerWeight(layer)”

Get/set a layer’s weight, so a game can fade an overlay in and out (ease an aim/lean layer to 0 when the weapon holsters) without triggering an Action.

Parameters:

  • layer (String or Num) — the authored [[layers]] name, or its 1-based index (positional in the [[layers]] array; 0 is the base layer). Prefer the name: the index shifts if a layer is inserted.
  • weight (Num) — clamped to 0..1. 0 means the layer contributes nothing.

Returns (layerWeight): Num — the current weight; 0 for an unknown name or an undefined layer, and 1 for the base layer (0).

An unknown layer name is a no-op on the setter, not an error — a tool driving a graph mid-edit routinely names a layer that doesn’t exist yet.

ctrl.setLayerWeight("upper_body", 0.0) // fade the overlay out
ctrl.setLayerWeight(1, 1.0) // ...or address it positionally
System.print(ctrl.layerWeight("upper_body"))

A TOML-authored layer starts at weight 1 (the schema has no weight field — it is a runtime value). The node editor’s 3D preview exposes these as live per-layer sliders.

Phase queries report on the primary clip — the highest-weighted active clip.

Property / methodDescription
currentStateThe active state / played-clip name ("" when nothing is playing)
normalizedTimePlayback progress 0..1 of the primary clip (0 when nothing is playing)
inSection(name)true when the primary clip’s normalized time is within a tagged section

Sections are author-tagged normalized ranges within a clip (e.g. an attack’s windup / active / recover), reported as the clip plays.

Register per-activity callbacks. The engine invokes them from its per-frame auto-tick. onEnter fires when an activity becomes current, onExit when it leaves, onUpdate every tick, and onEnd on the latch-and-refire END — once when a non-looping clip finishes (latched so a listener can chain the next play) and again on every loop wrap.

MethodCallback receives
onEnter(name, fn)the activity name
onUpdate(name, fn)the activity name and dt
onExit(name, fn)the activity name
onEnd(name, fn)the activity name (latch-and-refire END)
ctrl.onEnter("attack") { |name| System.print("entered %(name)") }
ctrl.onEnd("attack") { |name| ctrl.play("idle") } // chain on completion

A graph transition can raise a named event, and you can watch transitions generically:

MethodFires whenCallback receives
onEvent(name, fn)a transition whose event = "name" commitsthe event name
onTransition(fn)every committed transitionthe from and to state names

Give any transition an event in the TOML (works for per-state and [any_state] edges); it’s raised when that edge commits. onEvent is keyed by event name (like onEnter); onTransition is a single firehose. The order per transition is onExit(from)onEnter(to)onTransitiononEvent.

// [states.idle] transitions = [ { to = "attack", guard = "atk", event = "AttackStarted" } ]
ctrl.onEvent("AttackStarted") { |name| playSound("swing") }
ctrl.onTransition { |from, to| System.print("%(from) -> %(to)") }

StateBehaviour is a reusable object attached to a state — Unity’s StateMachineBehaviour as pure sugar over the per-state hooks. Subclass it, override any of onEnter(state) / onUpdate(state, dt) / onExit(state) / onEnd(state) (all no-ops by default), then attach(controller, state); the engine’s auto-tick drives it. Keep a reference so it isn’t collected.

import "engine" for StateBehaviour
class Patrol is StateBehaviour {
construct new(speed) { _speed = speed }
onEnter(state) { System.print("begin patrol") }
onUpdate(state, dt) { /* per-frame logic while in the state */ }
onExit(state) { System.print("leave patrol") }
}
_patrol = Patrol.new(2.0).attach(ctrl, "patrol")

Attaching replaces any hook previously registered for that state, so attach at most one behaviour per state. It’s per-state (like Unity’s SMB); the global onEvent / onTransition above stay on the controller.

rootMotionDelta is this frame’s extracted root-motion delta as a 3-element list [x, y, z], in the root bone’s local (character-relative) space. It is read-only: the engine strips the motion from the pose (so the mesh stays put) and hands you the delta to apply to the character’s physics body yourself, rotated by the character’s facing.

var d = ctrl.rootMotionDelta // [x, y, z]

tick(dt) advances the controller explicitly. The engine already auto-ticks attached controllers each frame, so call this only for manual/paused stepping.

ctrl.tick(dt)

Authoring the graph — the TOML controller asset

Section titled “Authoring the graph — the TOML controller asset”

The graph (states, blend nodes, transitions, layers, root motion) is authored in a TOML asset, loaded by node.attachController(path). Blender is the clip source only — the graph never lives in the .blend. References are by name; unknown/dangling references, duplicate ids, malformed guards or thresholds, and out-of-range values are rejected at load with a diagnostic (the previous graph is kept on a hot-reload error). Malformed asset files are validated and bounded at the parser boundary.

[controller]
name = "biped"
default_state = "locomotion"
# Typed params: blend inputs AND guard variables.
[params]
speed = { type = "float", default = 0.0 }
grounded = { type = "bool", default = true }
attack = { type = "trigger" } # auto-resets after firing one transition
# Clips reference a registered Blender action; optional tagged sections (normalized 0..1).
[clips.idle]
action = "Idle"
loop = true
[clips.attack]
action = "Attack"
loop = false
sections = [
{ name = "windup", from = 0.0, to = 0.3 },
{ name = "active", from = 0.3, to = 0.6 },
{ name = "recover", from = 0.6, to = 1.0 },
]
# Blend nodes ("mixer-as-state"): blend1d + blend2d (Cartesian) only.
[nodes.locomotion]
type = "blend1d"
param = "speed"
entries = [ { clip = "idle", at = 0.0 }, { clip = "walk", at = 1.5 }, { clip = "run", at = 4.0 } ]
# Transitions as standalone value-objects: a controller.play(name) target (and, in a later update,
# an Action's play). clip + fade + speed + start_time.
[transitions.to_attack]
clip = "attack"
fade = 0.12
speed = 1.0
start_time = 0.0
# States reference a blend node OR a clip. Guards: `param OP value`, a bare trigger name, or END.
[states.locomotion]
node = "locomotion"
transitions = [
{ to = "jump", guard = "attack", fade = 0.10, event = "Jumped" }, # `event` raised on commit
{ to = "fall", guard = "grounded == false", fade = 0.15 },
]
[states.jump]
clip = "jump"
transitions = [ { to = "locomotion", guard = "END", fade = 0.20 } ] # END = latch-and-refire
# Any State: global transitions evaluated every tick from WHATEVER state is current — a Mecanim-style
# "Any State". One edge reaches every state (and future ones) without wiring "-> death" out of each.
[any_state]
transitions = [
{ to = "death", guard = "die", fade = 0.05 }, # fire from anywhere
{ to = "hit", guard = "hit", fade = 0.05, self = false }, # self=false: won't restart hit while in it
]
# Masked overlay layer(s): blend = "override" | "additive"; mask = bone names.
[[layers]]
name = "upper_body"
blend = "override"
mask = [ "spine", "shoulder.L", "shoulder.R", "upper_arm.L", "upper_arm.R" ]
# Actions: a transient overlay on a masked layer, played via ctrl.action(name), completing on END.
[actions.attack]
layer = "upper_body"
transition = "to_attack"
# Contexts: a modal gate over params -> which states/actions are permitted.
[contexts.grounded]
when = "grounded == true"
allows = [ "attack" ]
# Root motion: designated bone + extraction mode (read-only delta in v1).
[root_motion]
bone = "root"
mode = "xz" # extract XZ translation; keep the rest in-pose

Guards are one of: param OP value (OP ∈ == != < <= > >=), a bare trigger name (true iff set; consumed on fire), or END (the state’s non-looping clip finished — latch-and-refire). A state lists transitions top-to-bottom; the first satisfied wins.

The optional [any_state] table holds global transitions — evaluated every tick from whatever state is current (a Mecanim-style “Any State”). Use it for edges that must be reachable from everywhere — death, hit-reaction, a global reset — without hand-wiring the same edge out of every state (and re-wiring it when you add one). Each entry is an ordinary { to, guard, fade } transition plus one optional field:

  • self (bool, default false) — Can Transition To Self. When false, the edge is suppressed while its target is the current state, so a still-true guard can’t restart the state you’re already in every tick. Set true to allow re-entry.

Each tick the any-state edges are checked first (a global override), then the current state’s own transitions; first satisfied wins. A trigger is consumed only when a transition actually commits, so a self-suppressed edge never swallows it. END is not allowed on a global edge (it has no single source clip). Omitting [any_state] behaves exactly as before.

See Actions & Contexts above for the [actions]/[contexts] runtime.