# 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`: ```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 metadata** — `AnimatorAsCode()`, `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. ```rhai 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: ```rhai 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: ```rhai 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: ```rhai 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`: ```rhai clip.toggle("Body/Props", true); // enable at frame 0, hold clip.toggle("Body/Props", false); // disable ``` ### Blend Shapes Animate a blend shape weight: ```rhai 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: ```rhai 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: ```rhai 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: ```rhai 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: ```rhai 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: ```rhai 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: ```rhai 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: ```rhai 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: ```rhai 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: ```rhai tree.automatic_thresholds(true); ``` ## Transitions Transitions connect states. You create one by calling `transition_to` on the source state: ```rhai 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: ```rhai 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 `!=`: ```rhai 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`: ```rhai 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 ```rhai // 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: ```rhai walk.transition_to(walk).when(speed > 4.0).to_self().no_exit_time().duration(0.0); ``` ### Interruption Settings ```rhai 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: ```rhai 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: ```rhai 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: ```rhai 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: ```rhai // 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: ```rhai 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.