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.
Attaching
Section titled “Attaching”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).
Typed parameters
Section titled “Typed parameters”Parameters are the controller’s blend inputs and (with the graph) transition-guard variables.
| Method | Description |
|---|---|
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")Direct play (escape hatch)
Section titled “Direct play (escape hatch)”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-objectctrl.play("Wave") // a bare clip actionActions & Contexts
Section titled “Actions & Contexts”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 untouchedA 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.
Layers
Section titled “Layers”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(StringorNum) — the authored[[layers]]name, or its 1-based index (positional in the[[layers]]array;0is the base layer). Prefer the name: the index shifts if a layer is inserted.weight(Num) — clamped to0..1.0means 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 outctrl.setLayerWeight(1, 1.0) // ...or address it positionallySystem.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.
Clip phase
Section titled “Clip phase”Phase queries report on the primary clip — the highest-weighted active clip.
| Property / method | Description |
|---|---|
currentState | The active state / played-clip name ("" when nothing is playing) |
normalizedTime | Playback 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.
Behaviour hooks
Section titled “Behaviour hooks”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.
| Method | Callback 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 completionTransition events
Section titled “Transition events”A graph transition can raise a named event, and you can watch transitions generically:
| Method | Fires when | Callback receives |
|---|---|---|
onEvent(name, fn) | a transition whose event = "name" commits | the event name |
onTransition(fn) | every committed transition | the 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) → onTransition →
onEvent.
// [states.idle] transitions = [ { to = "attack", guard = "atk", event = "AttackStarted" } ]ctrl.onEvent("AttackStarted") { |name| playSound("swing") }ctrl.onTransition { |from, to| System.print("%(from) -> %(to)") }State behaviours
Section titled “State behaviours”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.
Root motion
Section titled “Root motion”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 = falsesections = [ { 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.12speed = 1.0start_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-poseGuards 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.
Any State (global transitions)
Section titled “Any State (global transitions)”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, defaultfalse) — Can Transition To Self. Whenfalse, 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. Settrueto 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.