Chine: Spine 4.3 in pure Rust
Everything that moves in this article is being posed right now, in your browser, by a RustA programming language known for being fast and for catching whole classes of bugs before a program ever runs. library compiled to WebAssemblyA compact binary format that browsers run at close to native speed. Languages such as Rust compile to it, so the same code can run natively and on the web.. There's no video and no GIF. Every frame, Chine reads a Spine skeleton, applies its animation, solves its constraints, steps its physics and hands back triangles, and a small WebGL2The browser's built-in API for drawing with the GPU, based on OpenGL ES 3.0. renderer draws them.
Chine is a Spine 4.3 runtime written in Rust. I wrote it because the game engine I've been building for the past year needed one, and the Rust runtime it started with stops at Spine 4.2. It's the first piece of that engine I'm releasing on its own, and as of today it's on crates.io.
(The name is the chine, the backbone.)
What a Spine runtime does
Spine, by Esoteric Software, is an editor for Skeletal animationAnimating a character by moving a hierarchy of bones that its images or meshes are pinned to, instead of drawing every frame. in 2D games. Instead of drawing every frame of a
character, an animator builds a skeleton of bones, pins images
and meshes to it, adds constraints, and keys the bones over
time. The editor exports two things: the skeleton, as a .json
or a binary .skel file, and a Texture atlasMany small images packed onto one larger image, with a file that records where each one sits, so the GPU can draw them all from one texture. holding the
images.
A runtime is what plays that export inside a game. Every frame it has to:
- advance the animation clock and blend whatever animations are playing,
- apply the keyed values to bones, slots and attachments,
- place every bone in the world, then let the constraints (Inverse kinematics (IK)Solving a chain of bones backward from a target: you say where the end should be, and the solver works out how each joint bends to reach it., transform, path, physics and slider) correct the result,
- turn the attachments into triangles a GPU (graphics processing unit)The chip that trains and runs AI models far faster than a computer's main processor. can draw.
All of that has to match Spine. When it doesn't, an animator's work looks wrong in the game, in ways that are hard to trace back to anything.
Why another runtime
The established Rust runtime, rusty_spine, is transpiled from Esoteric's C runtime, spine-c. It's good work, and it tops out at Spine 4.2, because Esoteric discontinued spine-c at 4.3. If you wanted current Spine in Rust, on native targets and in WebAssembly alike, there wasn't a runtime to use.
My engine had rusty_spine in its very first CommitA saved snapshot of changes in a repository, identified by a unique hash, so any past state of the code can be named and found again., back in October 2025. Spine was on the list from day one. In June the 4.2 ceiling started to matter, so on June 2 I started Chine. By June 4 the engine played Spine through it and rusty_spine was gone. The four months since went into making it complete and making it hard to break.
Chine is a from-scratch reimplementation. It reproduces the
behavior of Esoteric's official 4.3 runtimes, which I
referenced throughout, without porting or copying their code.
Its only dependencies are glam for the math and, when the
JSONA plain-text format for structured data, built from named fields and lists, that almost every programming language can read and write. loader is on, serde_json.
It stops at the draw call
Chine never touches a GPU. That was the first design decision, and most of the others follow from it. Chine poses a skeleton and hands back a list of draw commands, and whatever renders your game draws them.
Loading happens once. The rig is immutable and shared behind
an Arc, so a crowd of the same character costs one copy of
the data and one small Skeleton per instance:
use std::sync::Arc;
use chine::anim::AnimationState;
use chine::atlas::Atlas;
use chine::render::{bind_atlas, render};
use chine::skel::Skeleton;
// Load once. Use chine::binary::from_binary(&bytes) for a .skel export.
let atlas = Atlas::parse(&atlas_text);
let mut data = chine::load::from_json(&skeleton_json)?;
bind_atlas(&mut data, &atlas); // resolve attachment UVs and atlas pages
let mut skeleton = Skeleton::new(Arc::new(data));
let walk = skeleton.data().find_animation("walk").unwrap().clone();
let mut state = AnimationState::new();
state.set_animation(walk, true); // loop
// Every frame.
state.update(dt);
skeleton.update(dt); // physics
skeleton.set_bones_to_setup_pose();
state.apply(&mut skeleton);
skeleton.update_world_transform();
let commands = render(&skeleton);Each command is one attachment's worth of geometry, in world space, ready to upload:
pub struct RenderCommand {
pub positions: Vec<Vec2>, // world-space vertex positions
pub uvs: Vec<f32>, // UVs on the atlas page, two per vertex
pub triangles: Vec<u16>, // indices into positions
pub color: Color, // slot color times attachment color
pub dark_color: Option<Color>, // the dark half of a two-color tint
pub page: usize, // which atlas page to bind
pub blend: BlendMode, // normal, additive, multiply or screen
}Drawing them is the renderer's whole job: bind the page, set
the blend mode, upload the positions and UVs, and draw the
triangles with the tint. In a render loop, render_into refills
one buffer instead of building a new list every frame.
The whole loop is cheap. For spineboy-pro, Esoteric's 67-bone example with 7 IK and 7 transform constraints, one frame of posing and draw data takes about 16 µs (microsecond)A millionth of a second. on my laptop's i9-11900H. The fish above has 25 physics constraints and takes about 22 µs. A frame at 60 fps has 16,700 µs to spend.
What's in it
Chine covers the Spine 4.3 runtime feature set, from loading to draw data:
| Area | What Chine covers |
|---|---|
| Loading | JSON and binary .skel exports, and .atlas files in the 4.1+ format plus the common older keys |
| Skeleton | Bones with all five inherit modes, slots, skins, and the bones and constraints a skin requires |
| Attachments | Region, mesh (weighted or not), linked mesh, path, bounding box, point and clipping, and flipbook sequences |
| Constraints | IK with one or two bones, softness, stretch and compress, plus transform, path, physics and slider, in the order Spine updates them |
| Animation | Every 4.3 timeline, with stepped, linear and Bezier curves, on a multi-track AnimationState with crossfades and a queue |
| Drawing | Two-color tint, and polygon clipping, convex and inverse |
Physics is the 4.3 spring and damper model, with wind and gravity. It steps at a fixed rate, 60 times a second unless the rig says otherwise, however fast the game draws, so a slow frame gets more steps rather than a bigger, less stable one.
Skins are where Spine gets interesting for anyone who makes art. A skin can swap any attachment, and a linked mesh can reuse another mesh's geometry and bone weighting with different art, so one rig can wear any number of outfits without being rebuilt:
If three of those marks are new to you, that's on purpose for now. They belong to the engine, to my pixel-art editor, and to an emulator frontend I built on the engine. You'll hear more about all three.
Checked against Spine
"It matches Spine" is a claim, so here's how I check it.
I check against eight of the example projects that ship with
Spine's 4.3 editor: coin, diamond, mix-and-match, raptor,
spineboy, stretchyman, tank and vine. With their .json and .skel exports in place, cargo test loads
every rig through both loaders and requires the same bones and
the same pose for every animation and every skin. The two
loaders are written separately, so the only way they agree is by
both reading the format correctly.
This is the kind of bug that check exists for. Until 0.2.0, the binary loader read each bone's length before its inherit mode, the reverse of the order 4.3 writes them, so the official examples loaded with wrong bone lengths and inherit modes. The JSON exports of the same rigs were right, and a loader that disagrees with its twin fails that test.
The example rigs aren't in the RepositoryA project's files together with their full history of changes, usually kept with git and hosted on a service such as GitHub., because they're Esoteric's art. The rigs on this page are mine. A short script writes them as Spine 4.3 JSON exports, the same format the editor produces, so they can ship with the page.
Hard to break
A runtime reads files that come from somewhere else: a mod, a
download, a player's upload. Chine treats every export as
untrusted. A malformed .skel, JSON file or atlas returns an
error or loads as harmless data. It doesn't panic, hang or
allocate without bound, and the same holds for the times,
speeds and scales a host passes in.
Two things hold that line. The loaders cap what a short record can grow into. The binary loader refuses any count larger than the bytes left could hold, and the JSON loader charges every expansion, like a deform key that becomes a full vertex array or a linked mesh that becomes a copy of its source, against a budget of 64 MiB (mebibyte)1,048,576 bytes, a little more than a million. plus 256 bytes per byte of input. A tiny file can't ask for gigabytes.
And there are four FuzzingTesting by feeding a program enormous numbers of generated and mutated inputs and watching for crashes, hangs and runaway memory. cargo-fuzz runs it for Rust code. targets: the binary loader, the JSON loader, the atlas ParserA program that reads text or source code and works out its structure. Parsers tend to break when the format they expect changes., and posing. Earlier rounds saved 9,742 inputs that crashed Chine. All of them now load or fail cleanly, and a final 45-minute round on all four targets, about 193 million runs, found nothing. The same hardening pass turned up one bug that had nothing to do with bad input: a path constraint in tangent mode could panic on a perfectly valid rig, because a buffer was one sample short.
Around that sit 300 tests, 291 in Chine and 9 in chine-web.
In the browser
chine-web compiles Chine to WebAssembly and adds the only web-specific parts: a WebGL2 renderer for the draw commands and a custom element that loads a rig and runs the loop.
<script type="module" src="chine-web/js/chine-spine.js"></script>
<chine-spine
atlas="diamond-pro.atlas"
skeleton="diamond-pro.skel"
animation="idle-rotating"
style="width: 600px; height: 600px"
></chine-spine>It also registers as <spine-skeleton>, the element Spine's own
HTMLThe language web pages are written in. A page can include scripts that run in the browser of whoever opens it. export uses, and reads the data that export embeds, so an
exported page plays through Chine when you swap one script tag.
Built for size, the WebAssembly is 337 KB, or 128 KB gzipped, with only the binary loader, which is what most exports use. The figures on this page need the JSON loader, since I wrote their rigs as JSON, and that build is 463 KB, or 172 KB gzipped. It only downloads on pages that have an animation.
The figures don't use the element. A small island calls the
same WebSpine API (application programming interface)The set of requests one program accepts from another. A web API is how apps, scripts and AI agents ask a service to read or change its data. directly, so a figure can stop drawing when
it scrolls out of view, offer the buttons under it, and wait for
Play when your system asks for reduced motion.
In the engine
Chine has two users today. chine-web is one. The other is Oniq, the engine I keep mentioning: a deterministic game engine in Rust that runs natively, in the browser and on Android from one WebGPUThe newer graphics and compute API for the web, designed around modern GPUs. Native programs use it too, through libraries such as wgpu. codebase. It isn't public yet, and this isn't the article about it, but this is how Spine looks from the inside:
let atlas = Atlas::parse(ATLAS_TEXT);
let skeleton = SpineSkeleton::from_json(SKELETON_JSON, &atlas, vec![PAGE_PNG.to_vec()])
.expect("load spineboy skeleton")
.with_animation("walk", true);
world.spawn((Transform::from_position(Vec3::new(0.0, -180.0, 0.0)), skeleton));
// Every frame: advance every skeleton in the world, then draw them with the sprites.
update_spine(ctx.world, ctx.delta_time);
render_spine(ctx);A skeleton is an Entity component system (ECS)A way to structure a game where each thing is an id with plain data components attached, and systems run over every entity that has the components they need. component like any other. Oniq takes Chine
0.2 from crates.ioThe public registry where Rust libraries, called crates, are published for anyone to use. behind a spine feature, and its draw step
appends every command to the same sprite batcher everything else
goes through, so skeletons and sprites share one frame and one
draw order, and Spine's blend modes map onto the engine's own
materials.
A third user is planned. Pixel Stroke, my pixel-art editor, also runs on Oniq, and the idea is to open a rig in it, repaint its atlas, and watch the result animate while you paint. The flag above is the smallest version of that idea: one rig, different art.
What it won't do
Chine doesn't draw. That's the point, but it means you bring a renderer, or use chine-web.
It doesn't write Spine files or edit rigs. It loads them.
It targets Spine 4.3. I haven't tested exports from older editors, and the binary format changes between versions.
Physics shows the latest whole step. Spine's runtimes interpolate between the last two steps, and Chine doesn't yet, so on a display faster than the physics rate a physics-driven bone can move in small jumps.
chine-web shows one skin at a time. Spine's element combines a comma-separated list of skins into one, and Chine has no public API for combining skins yet, so chine-web shows the first.
Oniq doesn't draw two-color tint yet. Chine emits the dark color and chine-web uses it, but the engine's batcher only reads the light one.
Events carry their name, values and audio settings. Playing the sound is the host's job.
Licensing
Chine's own code is MIT. Spine itself isn't free software, and
working with Spine data through any runtime, Chine included,
falls under Esoteric's Spine Runtimes License, which requires
each user to hold a Spine Editor
license.
Chine isn't affiliated with Esoteric Software or endorsed by
them. The credit for the format and the runtime design is
theirs, and Chine's LICENSE file carries their agreement in
full.
Current state
Chine 0.2.0 is on crates.io, the first release published there.
It covers the whole Spine 4.3 runtime feature set. What's left
on the roadmap is validation against more production exports,
beyond Esoteric's examples and my own rigs, and performance
passes on the pose and draw paths. chine-web isn't on crates.io.
It ships as a WebAssembly bundle you build with wasm-pack.
Getting it
[dependencies]
chine = "0.2"Both loaders are on by default. Turn off the json feature for
binary exports only, or the binary feature for JSON only. To
check a real export end to end, load, pose and render, without
a window:
cargo run --example inspect -- skeleton.json skeleton.atlasAnd to build the browser runtime:
wasm-pack build chine-web --target web --releaseThe source is on GitHub.
The backbone
A runtime is invisible when it's right. Nobody should notice Chine, only the animation an artist made, playing the way they made it. That's the bar I held it to, and it's why it gets checked against Spine's own examples, fuzzed, and kept out of the renderer's business. It's also the first piece of Oniq to stand on its own. It won't be the last.
