DEV Community

Joon Kyoung
Joon Kyoung

Posted on AI-assisted

I built a 2D rig compiler for AI agents, and the validator's messages are the whole UI

When I had an AI agent rig a 2D character, the only feedback it acted on was whether the file parsed, and a parse is not a look. That one observation is the whole design of rig-c, and this is the story of what follows from it: a compiler that writes nothing until its checks pass, a player that poses characters with <img> tags and CSS matrix(), and a public exam where fresh agent sessions try the tools cold. The toolchain's code is MIT, at https://github.com/rig-c (the example paintings carry their own licences, noted below), with a demo you can open at https://rig-c.org/examples/ .

The problem: parsers that accept broken rigs in silence

A 2D character rig is a skeleton laid over cut-up artwork: bones, slots that hold the part images, meshes with bone weights, constraints, and animations as keyed timelines. Spine's JSON format is the common currency, and it is what rig-c emits.

The trouble with letting an agent author that format is not that it is hard; it is that the failure modes are quiet. From the project's own list of what a standard parser lets through unremarked:

  • a 4.2-shaped constraint that simply vanishes in 4.3,
  • a mismatched size: that collapses every UV,
  • a four-number curve array that yields NaN,
  • a mesh whose vertex count equals its UV count, which silently loses its bone weights.

A human in the editor sees the limb detach and fixes it. An agent with only a parser for feedback reports success on a broken rig and is sincere about it. So the first design decision was to convert that silence into a named failure, and to treat the failure text as the product.

Design: five rules

1. The messages are the UI. Every refusal names the object, the value found and the value required. An agent reads one line and knows which field in which file to change. This is the compiler's own example, after renaming a part image and re-running rigc validate:

FAIL  A17_ATLAS_PAGE_FILES_EXIST: page "../images/hull.png" is not on disk at …/images/hull.png
rigc: 1 assertion(s) failed
Enter fullscreen mode Exit fullscreen mode

invalid mesh is not a message. A20_MESH_WEIGHTS_COHERENT: mesh "x" vertex 12 weights sum to 0.9000 is.

2. Nothing is invented. The compiler never guesses a default from the art, never re-measures a plate, never fills in a "reasonable" fallback. If a number is missing, that is a CompileError naming the field. A compiler that fills gaps produces rigs nobody can reason about, and the build manifest stops being a record of what was measured.

3. Emit only after green, and green is structural. rigc build compiles, validates, and writes to disk only if every named assertion of its profile passes: 36 under the default spine profile (is this valid Spine 4.3 that any runtime plays correctly?), all 50 under the opt-in rig-play profile. There is no --no-validate, no --emit-anyway, no environment escape. That is written into the repository's rules as a structural invariant, not a default. And green judges structure only: in the project's own words, a rig with its head off its torso passes the gate, because those are the offsets the spec asked for.

4. What was not checked is printed too. Every gate report names the profile that judged it and lists the rules that profile left out. The parts tool's check names each measured bar that said SKIP and why.

5. A person looks. rigc render and rigc inspect draw what was built; @rig-c/review puts it beside the source footage and exports feedback bound to a frame, bone or slot. The checks keep broken data off the disk; a human decides whether it looks right. The output imports into the Spine editor on purpose. The intended hand-off is: an AI drafts, a human refines in the editor. rig-c is not an editor replacement and makes no claim against one.

Holding the validator to Spine's own parser

A validator you wrote yourself is a claim, not a fact. The repository holds rig-c's validator to a round trip through @esotericsoftware/spine-core: in CI, on the public example recipes and every selftest, and on a private corpus measured before each release, every build is parsed by Spine's own runtime and the verdicts are compared. Of the 50 assertions, 49 run on both suppliers; the official parse itself (A00_ROUNDTRIP_PARSE) runs only where the runtime is installed and is reported as SKIP elsewhere.

The published package links no Spine runtime: spine-core is a development dependency. That is also what makes the licensing legible. rig-c's code is MIT. Which Spine terms apply depends on what plays the output: @rig-c/play and the engine adapters link nothing from Esoteric Software, so none apply there; an official Spine Runtime or the Spine editor bring their own terms. The licensing page spells it out in one table: https://rig-c.org/docs/licensing/ .

The toolchain, end to end, on a CPU

rig-c makes no images. The painting and its layer split come from image models you run yourself, on your GPU or a free web demo. rig-c starts from those files, ships no model weights, and runs every one of its own steps on the CPU.

  1. @rig-c/parts takes one character painting and the layers that See-through (an open-source layer-decomposition model) cut it into, merges them into rig-space parts, then drafts bones, weighted meshes, physics chains and a looping idle. Its README's demo character came out as 23 parts and a 3.3-second breathing loop from one command, rig-parts build --loop --lead-in 3, with one hand correction recorded in the config beside the value it replaced.
  2. @rig-c/pose cleans a body-18 pose sequence (from video, a dataset, or hand-placed points) and retargets it onto the rig as bone keys, gated before anything is written. There is no flag to skip the clean step; a sequence without the clean record is refused by name.
  3. rig-c compiles and gates it, as above.
  4. @rig-c/review is the human inspection workspace.
  5. @rig-c/play plays the compiled model in a page with no Spine runtime, and adapters bring it into PixiJS 8, Phaser 4.2+ and Three.js 0.186+.

Fourteen loose part PNGs assembling into a character that breathes, blinks and waves, rendered by rigc

The player: posing a rig with DOM elements

This is the part I think web developers will like. Every Spine web runtime rasterizes into a canvas, but the pose itself is computed on the CPU, and for a rigid part the bone transform is a plain affine map. CSS transform: matrix() expresses that map exactly. So rig-c's player puts the rigid tier in the DOM: one absolutely positioned <img> per slot, a single matrix() write per frame, composited by the browser, inspectable in devtools, styleable with CSS. Deforming meshes go to small per-part canvases; clipping is element-level clip-path; a dirty-skip avoids writes for unchanged elements. The idea had come up on the Spine forum more than once since 2014; I did not find a usable implementation, so this is mine. There is also a one-canvas mode for when you want it.

A minimal page is about twenty lines (this is the README's, trimmed):

<div id="stage" style="position: relative; width: 480px; height: 640px; overflow: hidden"></div>
<script type="module">
  import { loadModel, RigPlayer } from 'https://cdn.jsdelivr.net/npm/@rig-c/play@1/dist/index.js';
  const stage = document.getElementById('stage');
  const assets = await loadModel({ modelUrl: 'rigs/look/skeleton.model.json' });
  const root = stage.appendChild(document.createElement('div'));
  const player = new RigPlayer(root, assets);
  player.play(assets.animations[0].name);
  const box = assets.stage ?? player.bounds();
  const scale = Math.min(stage.clientWidth / box.width, stage.clientHeight / box.height);
  root.style.cssText = `position: absolute; transform-origin: 0 0; transform: scale(${scale});
    left: ${-box.x * scale}px; top: ${(box.y + box.height) * scale}px`;
  player.renderer.syncPixelRatio();
  let last = performance.now();
  requestAnimationFrame(function tick(now) { player.frame((now - last) / 1000); last = now; requestAnimationFrame(tick); });
</script>
Enter fullscreen mode Exit fullscreen mode

The one thing to know: syncPixelRatio() after any scale change, or the mesh canvases raster at the unscaled rig's device pixel ratio and burn 1/scale² times more pixels than the screen shows (a uniform scale shrinks both axes, so at 0.25 that is 16×, not 4×). The README keeps a standing benchmark against @esotericsoftware/spine-webgl 4.3.13 with the machine, the Chromium build, the frame counts and the load guard written down.

And in a game engine, the same model, with PixiJS 8 (from the getting-started guide):

import { Application } from 'pixi.js';
import { loadModel } from '@rig-c/play';
import { PixiRig } from '@rig-c/pixi';

const app = new Application();
await app.init({ width: 800, height: 600, backgroundAlpha: 0 });
document.body.appendChild(app.canvas);
const assets = await loadModel({ modelUrl: '/wave-build/skeleton.model.json' });
const rig = new PixiRig(assets, { ticker: app.ticker });
rig.play('wave');
app.stage.addChild(rig);
Enter fullscreen mode Exit fullscreen mode

Usage: from a painting and its See-through layers to a playing rig in about ten minutes, no GPU

The getting-started guide (https://rig-c.org/docs/getting-started/) starts from a published example whose painting and See-through layer split are already made, so the GPU steps are skipped entirely, and runs the rest with Bun alone, no Python. The page records its own timings on an Apple M4 with other jobs loading the machine: 536 seconds from an empty Bun cache, 571 seconds on a warm second run; most of that is the three builds. The shape of it:

bun add @rig-c/parts rig-c @rig-c/pose @rig-c/play
# fetch the example painting + See-through layers (CC BY-NC 4.0, credited in the guide)
bunx rig-parts inputs   --source inputs/painting.png --config config.json --out work
bunx rig-parts assemble --source inputs/painting.png --full inputs/layers/full --head inputs/layers/head --config config.json --out work
bunx rig-parts propose  --parts work/rig --source inputs/painting.png --out work --keypoints keypoints.json
bunx rig-parts build    --config config.json --source inputs/painting.png --full inputs/layers/full --head inputs/layers/head --out out --loop
Enter fullscreen mode Exit fullscreen mode

What the guide shows you reading at each step is numbers, not adjectives: recomposite vs source: mean |d|=1.13, within 8: 97.8% after assembly, 44 bone(s) — 29 checked and clean, 0 checked with a LINT line, 15 not checked after the bone proposal, and check: PASS; 11 of 13 bar(s) measured, 2 skipped (FEET_STAY_LEVEL, STRESS_OFF_CHAIN) then build: PASS at the end. If any line says FAIL, nothing was written, and that line says what to change.

The guide's one hand correction is instructive about the "nothing is invented" rule. The pose script put the left ankle 12 px off the leg's art; propose did not quietly move it, it printed a LINT naming the joint, the bone and the distance, and drafted the leg without an ankle cut. Moving the joint onto the shin by hand (one line) let propose cut the shoes into two feet. A LINT is something to look at, not a refusal.

Then a wave from joint positions, through @rig-c/pose:

bunx rig-pose clean --pose wave.pose.json --out wave.pose.clean.json
bunx rig-pose retarget --rig out/rig --map map.json --pose wave.pose.clean.json --out wave.motion.json --name wave --profile spine-html --build wave-build
Enter fullscreen mode Exit fullscreen mode

and the whole thing plays in the browser with @rig-c/play. The same build imports into a Spine 4.3 editor for a person to refine.

The parts that did not work, on the record

Two things I decided early and would do again.

Trials are kept whether the answer was yes or no. The examples repository has a trials/ directory with one rule for every entry: a question, the inputs and their origin, every command and pin, the measurement with its threshold stated before measuring, a verdict of PASS, FAIL or PARTIAL, the rejected alternatives, and the limits. A failed trial is as much a deliverable as a passing one; it is what stops the next attempt from repeating it. The README is explicit that a bar fitted to the result is not a bar.

Public numbers carry their failures beside them. The core README's benchmark section reports that all eight numbered rungs of Spine's own example-project ladder clear the gate, with the thinnest margin written down (worst slot drift 5.5550 px against a 6.0 px bar). The next paragraph reports the harder series, from-zero attempts at the graduation rig with no inherited specs: 18.2, 18.8, 19.57, 7.86, 9.33 and 18.98 px, every one above the bar, with no monotone trend. The certification is scoped to "tool + guide + protocol across a bounded series of honest attempts," not to "an agent does it in one run from the brief," because the second has not been demonstrated.

One limit the pose package states plainly: retargeting a single designed drawing through intense motion (a run, a dance) does not produce what the drawing lacks. Its README has a table of which approach fits which figure and motion, and a journey document of every lever tried on the way there with its measurement.

The exam: does the premise hold for a stranger?

If the messages are the UI, then a fresh agent that has never seen the tools should be able to build a rig with nothing but what ships in the package: README.md, docs/, --help. rig-c/exam tests exactly that. Before every command that changes a file, the examinee writes what it read and what it expects into a journal. Wherever expectation and reality differ, a tool message or guide was missing something, and those journal entries became issues.

On 2026-10-20, eight models from three families sat all five tasks, each in a fresh session, each regraded by the proctor from the files it handed in. Thirteen independent sittings are on the board so far. The machine-graded lines on the dance task read whether the loop closes, the hands and trunk travel, the arms bend at their joints, she stays on her feet, the motion keeps the beat; they do not read whether it is a dance. So the proctor rendered each run's dance as a clip, lettered with no name, and one person judged all eight blind. The six runs that passed every line were scored from 3 to 9 out of 10. Passing the lines is what a run needs to be judged at all; it says almost nothing about the score. The report frames its own caveat: one sitting per model and one judge, so it is not a finding about the models.

That is the loop I would recommend to anyone building tools for agents: write the refusal messages, then put a stranger in front of them and read the journal.

Where to go

The packages also ship Agent Skills (skills/<name>/SKILL.md) so Claude Code, Codex, Gemini CLI or Antigravity can find the authoring rules without being told where they are.

If you try it and a message leaves you not knowing what to change, that is a bug in the UI by this project's own definition. Please file it.


Disclosure: this article was drafted with AI assistance and edited and fact-checked by me against the project's public pages.

Top comments (0)