Files
av3-animation-as-crab/WRITING_SCRIPTS.md
T

14 KiB

Writing Animator Scripts in Rhai

A practical guide to writing .rhai files that generate Unity Animator Controllers.

Quick Start

Create a file called avatar.rhai:

let aac = AnimatorAsCode();
aac.system_name("MyAvatar");
aac.asset_key("AAC_");

let speed = aac.float_param("Speed", 0.0);

let ctrl = aac.new_controller();
let base = ctrl.layer("Base");

let idle = base.state("Idle", 0, 0);
let walk = base.state("Walk", 1, 0);

let idle_clip = aac.clip("idle_anim");
idle_clip.looping(true);
idle_clip.toggle("Body/Props", true);

idle.set_clip(idle_clip);
idle.transition_to(walk).when(speed > 0.1).no_exit_time().duration(0.25);

Open Tools > Animator As Crab in Unity, point it at this file, pick your controller and animator root, and hit Generate.

How Scripts Are Structured

Every script follows the same pattern:

  1. Create the context and set metadataAnimatorAsCode(), system_name, asset_key.
  2. Declare parameters — floats, ints, bools. These become the controller's parameters.
  3. Create the controller and layers — one controller, one or more layers.
  4. Declare clips and blend trees — the motions your states will use.
  5. Create states and assign motions — place them on the grid.
  6. Wire up transitions — conditions, timing, interruption settings.

The order within each group matters (parameters before layers, clips before states that use them), but the groups themselves can be interleaved freely — you can declare a clip right before the state that uses it, or batch all clips at the top.

Parameters

Parameters are the knobs your animator exposes at runtime. Declare them early — layers and transitions reference them by name.

let speed = aac.float_param("Speed", 0.0);     // FloatParam, default 0.0
let gesture = aac.int_param("Gesture", 0);      // IntParam, default 0
let is_sitting = aac.bool_param("IsSitting", false); // BoolParam, default false

You get typed handles back. The same handle is used both in transition conditions and as blend tree parameters — the type is enforced at validation time, not at declaration time.

Duplicate names are rejected immediately with a clear error.

States and Grid Position

States live on a layer. The last two arguments are the (x, y) position in Unity's animator graph editor — purely cosmetic, but useful for keeping things organized:

let idle = base.state("Idle", 0, 0);    // top-left
let walk = base.state("Walk", 1, 0);    // one column right
let run  = base.state("Run", 2, 0);     // further right

State names must be unique within a layer (including across sub-state machines).

Clips

Generated Clips

Create a clip, configure it, then assign it to a state:

let idle_clip = aac.clip("idle_anim");
idle_clip.looping(true);
idle_clip.keyframe("Body/Hand", "m_IsActive", 0.0, 1.0);
idle_clip.blend_shape("Face", "Smile", 0.0, 0.0);
idle_clip.blend_shape("Face", "Smile", 1.0, 0.8);

idle.set_clip(idle_clip);

Clip names must be unique across the entire script.

Keyframes

Keyframes are always linear (tangents are zero). You add them by specifying a path, a property, a time, and a value:

clip.keyframe("Body/Hand", "m_IsActive", 0.0, 1.0);
clip.keyframe("Body/Hand", "m_IsActive", 1.0, 0.0);

Multiple keyframes on the same curve are sorted by time automatically — the order you write them doesn't matter.

Toggles

The most common pattern: turning a GameObject on or off. toggle is a shortcut that writes a two-keyframe constant on m_IsActive:

clip.toggle("Body/Props", true);   // enable at frame 0, hold
clip.toggle("Body/Props", false);  // disable

Blend Shapes

Animate a blend shape weight:

clip.blend_shape("Face", "Smile", 0.0, 0.0);   // start at 0
clip.blend_shape("Face", "Smile", 1.0, 0.8);   // end at 0.8

Curve Targets

The generator infers the Unity component type from the property name:

Property Component
m_IsActive GameObject
blendShape.* SkinnedMeshRenderer

Any other property name is rejected. If you need a different component type, extend infer_target in rust/src/graph.rs.

Animation Store

If you already have .anim files in your project, you can use them instead of generating clips from scratch:

let store = aac.AnimationStore("Assets/Doloro/Clips");
let walk = store.clip("Walk");       // loads Assets/Doloro/Clips/Walk.anim

The .anim extension is optional — store.clip("Walk") and store.clip("Walk.anim") resolve to the same asset and produce the same clip.

Editing Store Clips

Store clips are starting points. Anything you write on them — looping, keyframes, toggles — is applied to a clone. The original asset is never modified:

let walk = store.clip("Walk");
walk.looping(true);                    // override the clone's looping
walk.toggle("Body/Props/Umbrella", true); // add a keyframe on the clone

A store clip with no looping(...) call keeps the source asset's own looping setting.

Using Store Clips

Store clips work exactly like generated clips everywhere:

walk_state.set_clip(walk);             // assign to a state
locomotion.add_motion(walk, 0.0);      // add to a blend tree

Subfolders

Store paths can contain subfolders. The folder argument to AnimationStore and the clip name are joined directly:

let props = aac.AnimationStore("Assets/Doloro/Clips/Props");
let umbrella = props.clip("Weapons/Umbrella");
// → Assets/Doloro/Clips/Props/Weapons/Umbrella.anim

Missing Assets

If a store clip references an asset that doesn't exist, you'll get a clear error at generation time — not a silent failure.

Blend Trees

Blend trees mix multiple clips (or other blend trees) based on parameter values.

1D Blend Tree

The simplest kind — one parameter controls which clip plays:

let locomotion = aac.blend_tree("locomotion");
locomotion.simple_1d(speed);
locomotion.add_motion(idle_clip, 0.0);    // speed 0 → idle
locomotion.add_motion(walk_clip, 2.0);    // speed 2 → walk
locomotion.add_motion(run_clip, 5.0);     // speed 5 → run

The thresholds define the blend points. Unity interpolates between them.

2D Blend Trees

Two parameters control a 2D blend space:

let strafe = aac.blend_tree("strafe");
strafe.freeform_directional_2d(speed, vertical);
strafe.add_motion(forward_clip, 0.0, 1.0);   // (x, y) threshold
strafe.add_motion(back_clip, 0.0, -1.0);
strafe.add_motion(left_clip, -1.0, 0.0);
strafe.add_motion(right_clip, 1.0, 0.0);

Three 2D types are available:

Type Behavior
simple_directional_2d Clips are evenly distributed around a circle
freeform_directional_2d Clips can be at any angle, but magnitudes are normalized
freeform_cartesian_2d Clips can be at any position in the (x, y) plane

Direct Blend Tree

Each child is addressed by its own float parameter — useful for layered motion:

let hold = aac.blend_tree("hold");
hold.direct();
hold.add_motion_direct(umbrella_clip, grip);   // grip parameter drives this child
hold.add_motion_direct(sword_clip, sword_param);

Nesting Blend Trees

A blend tree can reference another blend tree that was declared earlier in the script:

let movement = aac.blend_tree("movement");
movement.simple_1d(speed);
movement.add_motion(walk_clip, 0.0);
movement.add_motion(run_clip, 5.0);

let strafe = aac.blend_tree("strafe");
strafe.freeform_directional_2d(speed, vertical);
strafe.add_motion(forward_clip, 0.0, 1.0);

let full_body = aac.blend_tree("full_body");
full_body.simple_1d(speed);
full_body.add_motion(movement, 0.0);    // nests the movement tree
full_body.add_motion(strafe, 5.0);      // nests the strafe tree

Cycles are impossible — you can only reference trees that exist at the point you reference them.

Automatic Thresholds

Let Unity figure out the blend thresholds:

tree.automatic_thresholds(true);

Transitions

Transitions connect states. You create one by calling transition_to on the source state:

idle.transition_to(walk);    // creates a transition from idle to walk

Adding Conditions

Chain .when(...) to add conditions. Conditions are written as comparisons on parameter handles:

idle.transition_to(walk).when(speed > 0.1);
walk.transition_to(run).when(speed > 4.0);

The comparison operators (>, <, ==, !=) are overloaded on parameter handles to produce conditions, not booleans. This is the core of the DSL — you write conditions exactly the way you'd think about them.

Bool parameters use == and !=:

any_state.transition_to(sit).when(is_sitting == true);
any_state.transition_to(stand).when(is_sitting != true);

Combining Conditions with when_all

Rhai's && and || cannot be overloaded — they're language built-ins. To combine multiple conditions on one transition, use when_all:

walk.transition_to(idle).when_all([speed < 0.05, is_sitting == false]);

This is the only DSL limitation with no workaround. Every multi-condition transition uses when_all.

Transition Timing

// Instant transition, no blending:
idle.transition_to(walk).when(speed > 0.1).no_exit_time().duration(0.0);

// 250ms cross-fade, fires immediately:
idle.transition_to(walk).when(speed > 0.1).no_exit_time().duration(0.25);

// Transition fires after 80% of the source animation plays:
idle.transition_to(walk).when(speed > 0.1).exit_time(0.8).duration(0.25);

no_exit_time() and exit_time(normalized) are mutually exclusive — the last one called wins.

Self-Transitions

By default, a transition to the same state is ignored. Enable it explicitly:

walk.transition_to(walk).when(speed > 4.0).to_self().no_exit_time().duration(0.0);

Interruption Settings

transition.ordered_interruption(false);   // disable ordered interruption
transition.source_interruption();         // enable source interruption

Any-State Transitions

Any-state transitions fire from every state in a machine, regardless of which state is active:

base.any_state().transition_to(sit).when(is_sitting == true).no_exit_time().duration(0.2);

This is the standard way to implement global interrupts — sit down from any animation, reset from any gesture, etc.

Any-state can also be scoped to a sub-state machine:

let gestures = base.sub_machine("Gestures", 2, 0);
gestures.any_state().transition_to(reset).when(gesture == 99);

Sub-State Machines

Group related states into a sub-state machine:

let gestures = base.sub_machine("Gestures", 2, 0);

let wave = gestures.state("Wave", 0, 0);
let point = gestures.state("Point", 1, 0);

wave.transition_to(point).when(gesture == 1).duration(0.1);
point.transition_to(wave).when(gesture == 0).duration(0.1);

Sub-state machines work exactly like the root machine — you can add states, transitions, any-state, and even nested sub-state machines to them.

Assigning Motions to States

States can hold a clip or a blend tree as their motion:

// From a generated clip:
idle.set_clip(idle_clip);
walk.set_motion(walk_clip);

// From a blend tree:
run.set_motion(locomotion);

// From a store clip:
sit.set_clip(store_clip);

set_clip and set_motion are interchangeable — use whichever reads better.

Complete Example

Here's a full script that demonstrates every feature:

let aac = AnimatorAsCode();
aac.system_name("MyAvatar");
aac.asset_key("AAC_");

// Parameters
let speed = aac.float_param("Speed", 0.0);
let is_sitting = aac.bool_param("IsSitting", false);
let gesture = aac.int_param("Gesture", 0);

// Controller and layer
let ctrl = aac.new_controller();
let base = ctrl.layer("Base");

// States
let idle = base.state("Idle", 0, 0);
let walk = base.state("Walk", 1, 0);
let run = base.state("Run", 2, 0);
let sit = base.state("Sit", 0, 1);

// Generated clips
let idle_clip = aac.clip("idle_anim");
idle_clip.looping(true);
idle_clip.keyframe("Body/Hand", "m_IsActive", 0.0, 1.0);
idle_clip.blend_shape("Body", "Smile", 0.0, 0.0);
idle_clip.blend_shape("Body", "Smile", 1.0, 0.8);

let walk_clip = aac.clip("walk_anim");
walk_clip.looping(true);
walk_clip.toggle("Body/Props", true);

let run_clip = aac.clip("run_anim");
run_clip.looping(true);
run_clip.toggle("Body/Props", false);

// Store clip
let store = aac.AnimationStore("Assets/Doloro/Clips");
let crouch_clip = store.clip("Crouch");
crouch_clip.looping(true);

// Assign clips
idle.set_clip(idle_clip);
walk.set_clip(walk_clip);
sit.set_clip(crouch_clip);

// Blend tree
let locomotion = aac.blend_tree("locomotion");
locomotion.simple_1d(speed);
locomotion.add_motion(walk_clip, 0.0);
locomotion.add_motion(run_clip, 5.0);
run.set_motion(locomotion);

// Transitions
idle.transition_to(walk).when(speed > 0.1).no_exit_time().duration(0.25);
walk.transition_to(run).when(speed > 4.0).no_exit_time().duration(0.25);
run.transition_to(walk).when(speed < 4.0).no_exit_time().duration(0.25);
walk.transition_to(idle).when_all([speed < 0.05, is_sitting == false]).duration(0.25);

// Any-state interrupt
base.any_state().transition_to(sit).when(is_sitting == true).no_exit_time().duration(0.2);

// Sub-state machine
let gestures = base.sub_machine("Gestures", 2, 0);
let peace = gestures.state("Peace", 0, 0);
let point = gestures.state("Point", 1, 0);
peace.transition_to(point).when(gesture == 1).duration(0.1);
point.transition_to(peace).when(gesture == 0).duration(0.1);

Tips

  • Start minimal. One layer, two states, one transition. Get it generating. Then add complexity incrementally.
  • Use the grid positions. (0, 0) is top-left. Spread states horizontally for logical flow, vertically for alternative branches.
  • Name clips descriptively. They become the asset names in Unity. "walk_anim" is clearer than "clip1".
  • Store clips for reused animations. If multiple avatars share the same .anim assets, a store avoids duplicating keyframes in every script.
  • when_all is your &&. Every multi-condition transition uses it. There's no way around this — it's a Rhai language limitation.
  • Test with aac-dump. Run cargo run --bin aac-dump -- your_script.rhai to see the JSON output without opening Unity. Catches script errors instantly.