How we generate Spine 4.2 JSON with an LLM
Spine JSON is fully documented, so the obvious plan is to ask a language model to write skeleton.json. We tried that path and left it: the model is good at deciding what should move and bad at keeping hundreds of numbers consistent. Here is the split that works, and the format traps that cost us the most.
The model calls tools, it never writes the file
Each turn runs our own tool-use loop. The provider receives a system prompt, the schemas of our tools and the conversation so far, and answers with one step: some text and some tool calls. The tools run in our process, where the rig lives, and their results go back into the conversation for the next step. The model has no terminal, no files and no network — only these tools.
The tools speak at the level of intent, not of the format: add a part, sway a bone, give a part secondary motion, turn a flat part as if it had volume. Each one knows the format and writes a correct piece of it; the model picks what and how much. A request like “the gold bounces a little from inertia” becomes a handful of calls, not a hand-written timeline.
Code measures, the model names
Where the bones go is a question about pixels, and a model looking at part sizes guesses. So the split is strict: code measures the silhouette of each part — its main axis by principal components, the ends of that axis, where it touches its neighbour — and the model says which part it is and which neighbour holds the joint. A bone then runs along the measured axis and starts where the part really meets the body. A round part has no direction of its own, so its bone goes to the centre — unless the model names the neighbour it hangs from, and then a head's bone starts at the neck.
The same rule applies to answers. The sign of a rotation depends on which way the bone points, and a model working it out blind gets it wrong about half the time. So the rig summary names the direction of each bone in words and degrees (points up, world 90°), and the answer to a motion translates its extreme keys into what happens on screen (the tip is 41 px higher than at rest).
Craft comes from measurements, not from the model
Asked for an idle, a model invents a length and a range. Ours looks them up: a reference of nine topics with numbers counted in the official Spine examples — the 30 fps grid, lengths by kind of motion, key density, ranges by bone role, where curves go and where they do not. The numbers and how they were measured are in Idle animation timing, measured on 127 official Spine animations. Motion tools also fill the whole animation: a sway with a period shorter than the animation repeats to its end, and a period that does not divide the length is refused with two periods that do. A single pulse followed by stillness is not an error anyone sees — it is a wasted turn.
A validator for the traps the runtime stays silent about
The official runtime is forgiving in the worst way: several mistakes load without a word and draw something wrong. Every file is checked before it is written, and these are the checks that paid off:
- Slot color is the one field written as a string, exactly eight hex digits
rrggbbaa. Six digits or a leading hash give no error — just an invisible or recolored part. - Mesh vertices: the vertex count comes from the length of
uvs, and whether the mesh is weighted is decided by comparingverticeswith it. One lost coordinate silently turns a plain mesh into a weighted one, and the runtime draws garbage. - Deform keys pointing at a missing or non-mesh attachment do not break one animation — they fail the loading of the whole skeleton.
- Bezier handles must stay inside the segment between two keys. Stretching an animation in time and forgetting that the first and third numbers of each curve are times leaves handles outside it.
The same checks run in your browser in the Spine JSON validator, with nothing uploaded.
Checking with the real runtime, without a browser
“Our JSON looks like the reference” proves little. The build of spine-webgl is a plain script, and its parser and skeleton math run in Node without WebGL once the global is returned from the wrapper and atlas pages get a stub texture:
const src = readFileSync("spine-webgl.js", "utf8");
const spine = new Function(src + ";return spine;")();
const atlas = new spine.TextureAtlas(atlasText);
for (const page of atlas.pages) page.texture = stubTexture; // only WebGL needs pixels
const data = new spine.SkeletonJson(new spine.AtlasAttachmentLoader(atlas))
.readSkeletonData(JSON.parse(skeletonJson));After that a test can apply an animation at a given time and ask where the runtime sees each mesh vertex. That is how one of the quietest traps was pinned down: the texture V axis points down while the world Y axis points up. Get it backwards and nothing fails — the image is simply mirrored, which on a symmetric part is invisible.
What goes in: a layered PSD
The art comes from the file an artist already has: a PSD read without Photoshop, where the shape of a part is often set by a layer mask rather than by transparency. How Riggy reads a PSD covers masks, clipping and [bone] groups; the pseudo-3D turn shows one of the tools from the inside, and the examples show the result in the official runtime.