← Rive animation overview
Problem: Multi-step animation pipeline, multiple animals, multiple gestures, both Python (offline) and Kotlin (runtime). Imperative scripts with hardcoded gesture names break the moment you add an animal or rename a state.
What I built:
  • Per-animal manifest defining the entire animation contract (artboard, ViewModel, state machine, group map, viseme inventory, phoneme→viseme mapping)
  • Wiring step that emits ~1,200 keyframe calls into a .riv from a 10-prompt deterministic sequence — no hardcoded gesture names anywhere
  • Kotlin loader that addresses the same state-machine inputs by name — Android consumes the same contract Python produced
  • One orchestrator command rebuilds an animal end-to-end
Result: Adding a 10th animal is a manifest change. The same wiring, scoring, and Kotlin code runs unchanged.
Stack: Python (orchestrator + wiring + validators), JSON manifests, Rive scripting via MCP, Kotlin (RiveAnimationView + state-machine inputs)
Learn to Read · Rive Animation · Architecture

Config-driven from manifest upstream to Kotlin downstream

Every step reads config. Every step writes config. No gesture name is hardcoded — not in the wiring, not in the scoring, not in the Kotlin. The contract is the manifest, and the manifest is the source of truth from the editor through to the kid's screen.

§1The 7 configs that flow through the pipeline

Every step in the pipeline is parameterized by config. The graph of who reads what:

Config fileRoleWritten byRead by
data/gestures_config.json Gesture → video map Hand-edited (or --add) rest_pose_model · schema_v3
data/rest_pose.json Universal rest baseline rest_pose_model.py generate_gesture_schema_v3.py
data/gesture_schema_v3.json Per-gesture magnitudes generate_gesture_schema_v3.py motion_curves · auto_gesture_specs · audit · postwire_eval
data/motion_curves.json Temporal motion shapes extract_motion_curves.py auto_gesture_specs.py
data/auto_gesture_specs.json Engine-ready track specs auto_gesture_specs.py wire_from_manifest.py
configs/parameter_mapping_spec.json Gesture ↔ runtime input contract Hand-authored (per animal) Wiring, Kotlin (indirectly via runtime inputs)
samples/<animal>_rive_assets/
rive_state_machine_manifest.json
Per-animal asset contract Hand-authored when SVG art lands Wiring, validators, Kotlin loader

Three contracts I'll show on this page: the per-animal manifest (§2), the wiring step that consumes it (§3), and the Kotlin loader that reads the same state-machine inputs the manifest declares (§5).

§2The per-animal manifest

One file per animal. Hand-authored once, when the SVG art lands. It declares everything the rest of the pipeline needs to know about this animal — artboard size, ViewModel name, state machine name, the names of the SVG groups for each body part, the viseme inventory, and the phoneme-to-viseme mapping that lip-syncs the animal to the phonics audio.

// new_pipeline/schemas/canonical_manifest_example.json
{
  "asset_name": "Example Animal",
  "artboard":    { "name": "Main", "width": 1024, "height": 1024 },
  "view_model":  { "name": "ExampleVM" },
  "state_machine":{ "name": "State Machine 1" },

  "group_map": {
    "root": "root",           "body": "body",
    "head": "head",           "arm_left": "arm_left",
    "arm_right": "arm_right", "leg_left": "leg_left",
    "leg_right": "leg_right", "ear_left": "ear_left",
    "ear_right": "ear_right", "pupil_left": "pupil_left",
    "pupil_right": "pupil_right",
    "mouth_visemes": "mouth_visemes",
    "tail": "tail",           "trunk": null,
    "shadow": "shadow",       "sparkles": "sparkles"
  },

  "viseme_groups": {
    "0": "mouthViseme_0_closed",  "1": "mouthViseme_1_narrow",
    "2": "mouthViseme_2_open",    "3": "mouthViseme_3_wide",
    "4": "mouthViseme_4_rounded", "5": "mouthViseme_5_smile"
  },

  "phoneme_viseme_indices": {
    "P": "0", "B": "0", "M": "0",    // closed mouth
    "S": "5", "Z": "5", "IY": "5",   // smile
    "AA": "3", "AE": "3", "AY": "3", // wide-open
    ...
  },

  "parts_metadata": {
    "head": {
      "parent": "root",
      "pivot":  [512, 320],
      "bounds": [320, 160, 704, 512],
      "allowed_props": ["rotation", "x", "y"],
      "role_class": "core"
    }
  }
}

The manifest is the contract. Anything that wants to talk about "the chick's left arm" goes through group_map["arm_left"]; anything that wants to celebrate goes through state_machine's isCelebrating input. Renaming a group in the SVG = one manifest edit, no code touched.

§3Wiring as a 10-prompt deterministic sequence

The wiring step is the bridge from data to .riv. It loads a copy of the asset, then emits ~1,200 keyframe calls in a fixed order. The order is mechanical, not heuristic — every animal goes through the same 10 phases:

# scripts/wire_from_manifest.py:780

def build_calls(manifest: dict, riv_path: str, output_path: str,
                intensity: float = 1.0, speed: float = 1.0) -> list:
    """Build the complete 10-prompt MCP call list."""
    cb = CallBuilder()
    gm = _build_gm(manifest)         # group_map resolution
    mo = _get_motion(manifest)       # motion overrides per animal

    cb.call("load_riv", {"path": riv_path})

    prompt_1_vm_and_sm(cb, manifest)              # ViewModel + State Machine
    prompt_2_verify_groups(cb, manifest)          # Verify SVG groups match manifest
    prompt_3_idle(cb, manifest, gm, mo)            # Idle animation
    prompt_4_blink(cb, manifest, gm)              # Blink overlay (independent layer)
    prompt_5_visemes(cb, manifest, gm)            # 6 mouth shapes (independent layer)
    prompt_6_poses(cb, manifest, gm, mo)           # All gesture poses
    prompt_7_celebrating(cb, manifest, gm, mo)     # Celebration (largest gesture)
    prompt_8_state_machine(cb, manifest)          # SM transitions, 3 layers
    prompt_9_verify(cb, manifest)                 # Post-wire structural audit
    prompt_10_export(cb, manifest, output_path)    # Export wired .riv

    return cb.calls
PROMPT 1
ViewModel + State Machine
Create the named VM and SM. Boolean inputs declared per parameter_mapping_spec.
PROMPT 2
Verify groups
Cross-check that every group_map name resolves to an SVG group. Fail loud, not silent.
PROMPT 3
Idle animation
Default loop. Tail, ear, breathing motion at small magnitudes. Schema-driven.
PROMPT 4
Blink overlay
Independent layer so blink fires over any state. Loops via timeline event.
PROMPT 5
Mouth visemes
6 mouth shapes (closed / narrow / open / wide / rounded / smile). Lip-sync layer.
PROMPT 6
Gesture poses
All 8 gestures emitted in a single phase. Each iterates the schema, never a hardcoded list.
PROMPT 7
Celebrating
Largest gesture; gets its own phase so it can apply intensity multipliers cleanly.
PROMPT 8
State machine
Wire transitions across 3 layers — main / blink / mouth. Schema-driven priorities.
PROMPT 9
Verify
Post-wire snapshot — list_objects, count animations, check VM properties exist.
PROMPT 10
Export
Write the wired .riv to the output path. Final phase, gated on prompt 9 passing.

The wiring step never touches the original art file — it loads a copy, emits keyframes into the copy, and exports. If anything fails, the original is intact. (This was a Step-0 lesson learned the hard way.)

§4Schema-driven everything — no hardcoded gesture names

The wiring step iterates the schema, not a hardcoded list. The contract module reads gestures dynamically from the manifest:

# new_pipeline/contracts/gestures.py:36 — fallback only, NOT the source of truth

_LEGACY_BOOL_INPUTS: dict[str, str] = {
    "walk_in":        "isWalkingIn",
    "look_at_letter": "isLookingAtTarget",
    "ask_child":      "isLookingAtChild",
    "waiting":        "isListening",
    "thinking":       "isThinking",
    "encouraging":    "isEncouraging",
    "helping":        "isHelping",
    "celebrating":    "isCelebrating",
}

def gesture_catalog(manifest: Mapping[str, Any] | None = None,
                    defaults: Mapping[str, Any] = DEFAULTS):
    """Read gestures from manifest dynamically. Legacy dict above is a fallback only."""
    return _gesture_catalog(manifest, defaults)
Why this matters: renaming a gesture, adding a gesture, or shipping a new animal that supports only a subset of gestures all become manifest changes. The wiring engine, validators, and scorer don't care about specific gesture names — they iterate whatever the manifest declares.

The fallback dict only fires when an old manifest pre-dates the gesture-catalog schema. New manifests carry their own gesture list; the engine reads it.

§5Kotlin consumption — same contract, runtime side

The Android app loads assets/animals/<animal>.riv and addresses the state-machine inputs by name. The names are the same names declared in the per-animal manifest. The contract crosses the Python ↔ Kotlin boundary as a stable schema.

// app/src/main/java/com/readingpractice/ui/components/RiveGuideAnimal.kt:105

private fun resolveRiveSource(animalName: String, context: Context): RiveSource? {
    if (animalName in ANIMALS_WITH_INDIVIDUAL_RIV) {
        val individualFile = "animals/$animalName.riv"
        val exists = try { context.assets.open(individualFile).use { true } }
                     catch (_: IOException) { false }
        if (exists) return RiveSource(individualFile, "Main", true)
    }
    return null
}

// RiveAnimalController — sets state-machine inputs by name
fun setState(state: GuideAnimalState) {
    if (!hasInputs) return
    safeBool("isLookingAtTarget", state == GuideAnimalState.WALKING_IN)
    safeBool("isCelebrating",    state == GuideAnimalState.CELEBRATING)
    safeBool("isEncouraging",    state == GuideAnimalState.ENCOURAGING)
    safeBool("isThinking",       state == GuideAnimalState.THINKING)
    safeBool("isLookingAtChild", state == GuideAnimalState.ASK_CHILD)
    ...
}

fun setMouthVisemeRaw(index: Int) {
    view.setNumberState(STATE_MACHINE, "mouthViseme", index.toFloat())
}

Same string keys end-to-end: isCelebrating declared in the manifest → wired into the state machine by Python → addressed by name from Kotlin. No mapping table, no version skew, no rename surprises.

§6One command — end-to-end rebuild

The orchestrator (scripts/gesture_factory.py) drives the whole pipeline. From a fresh recording to a wired .riv ready to drop into the Android assets:

$python gesture_factory.py --add stomping stomping.mp4 --wire chick

What runs:

  1. Step 0 — append stomping to gestures_config.json
  2. Step 1rest_pose_model.py recomputes universal rest if missing
  3. Step 2generate_gesture_schema_v3.py measures the new gesture (--gesture stomping) into gesture_schema_v3.json
  4. Step 3extract_motion_curves.py turns the schema into temporal curves
  5. Step 4auto_gesture_specs.py turns curves into engine-ready track specs
  6. Step 5gesture_audit.py validates physical limits + semantic rules
  7. Step 6wire_from_manifest.py emits the 10-prompt sequence into a copy of chick.riv and exports the wired version to workbench/chick/chick_wired.riv

Reproducible end-to-end. Every step is independent — failures show which step. Every step writes a config that the next step reads — no implicit state.

§7The May refactor — manifest-first from scratch

The legacy pipeline above was prompt-era — built around mutating an existing .riv file with the Rive scripting interface. The May refactor (new_pipeline/) is the long-term architecture: full .riv construction from scratch in one session, manifest-first.

// new_pipeline/README.md

This subtree contains the long-term manifest-first architecture for Rive animal automation.
It exists separately from the current prompt-era scripts so the migration is explicit and low-risk.

Scope:
- canonical asset contract
- canonical manifest schema
- pack preflight validation
- geometry-aware validation
- motion-aware validation
- manifest-first validation/orchestration
- from-scratch .riv build target

The production target for new animals is now full .riv construction from scratch in one session.

Subdirectories: builder/, contracts/, validators/, fixtures/, runner/, tests/ — 96 Python files, organized by responsibility. The legacy scripts still drive production today; new_pipeline/ is the destination, with a deliberate parallel-run period to keep migration low-risk.

The interesting engineering claim isn't "this animation pipeline works". It's "this pipeline scales horizontally." Adding the 10th animal is a manifest. Adding the 9th gesture is a recording. The shape of the work is fixed; new content slots in.