# machin-ressort
A 2D game engine written in [machin](https://github.com/javimosch/machin) (MFL), and
a playable Demolition Man stage built on it.
```
╔═══════════════════════════════════════════════╗
║ r e s s o r t ║
║ ───────────── ║
║ Torque2D · scenes and behaviours as DATA ║
║ Spring · one deterministic simulation ║
╚═══════════════════════════════════════════════╝
```
*ressort* is French for **spring** — and specifically for a **torsion spring**. Torque,
and Spring. The two engines it is built out of.
> **Case study — [Where a tool should stop, and ask](https://hart.intrane.fr/a/javimosch/machin-ressort-sprite-tool)**
> · write-up: [blog.intrane.fr](https://blog.intrane.fr/where-a-tool-should-stop)
> How the sprite pipeline below got built, what it cost, and the line between what a tool
> can measure and what only the agent driving it can judge.

---
## What it takes from each
**From [Torque2D](https://github.com/TorqueGameEngines/Torque2D)** — the idea that a
scene, a prefab and an object's behaviour are *data*, not code. Torque2D calls the
format TAML and serialises an object graph into XML. ressort keeps the idea and drops
the DOM: a level is a line-oriented text file the engine parses at startup.
```
scene name=stage1-wasteland gravity=1900 tile=32 seed=20321996
set hostages=4
layer name=skyline depth=0.86 color=38,22,58 y=120 h=460 kind=1
prefab gunner kind=2 hw=9 hh=17 hp=3 beh=gravity,collider,facing,ai,hurtbox,touchhurt
spawn gunner at=14,5
spawn gunner at=31,5 hp=7
entity kind=1 name=spartan at=6,1 hw=9 hh=15 hp=100 beh=gravity,collider,actor,camtarget
tiles
.#...,......,.......#............,..........
######################################..T.###
#.............................,.........H...
end
```
Torque2D attaches heap-allocated Behavior objects to a SceneObject. MFL has no dynamic
dispatch and cannot put a C struct inside an MFL struct, so ressort keeps the authoring
model and changes the representation: **every entity is one flat row, and its behaviours
are a bitmask**. Systems sweep the table and act on the rows whose bits match. Same
composition, no pointer chasing, and the whole scene is one contiguous value.
**From [Spring](https://github.com/spring/spring)** — the deterministic simulation and
the synced/unsynced split. Spring runs one simulation identically on every client and
ships only the players' commands; bit-equality is what keeps them agreeing. Its Lua is
split in two: *synced* gadgets may change gameplay and must stay deterministic, *unsynced*
code is UI and may differ per client — it can read synced state but never write it.
ressort borrows that shape and points it somewhere else. Instead of keeping N players in
sync, it keeps **a run in sync with itself**.
---
## The one thing this engine does that others don't
A playthrough is an **artifact you can replay and verify**.
```console
$ ./bin/demoman sim --frames 20000 --record run.rr
{"frames":20000,"checksum":"7899bb1ad171ac0f","score":4125,"kills":13,
"hostages":"4/4","lives":2,"phase":1,"entities":32,"spawned":11,"done":0}
$ ./bin/demoman verify run.rr
{"verified":true,"frames":20000,"checkpoints":335,"checksum":"7899bb1ad171ac0f"}
$ echo $?
0
```
Corrupt one checkpoint in that file and it says exactly where:
```console
$ ./bin/demoman verify tampered.rr
{"verified":false,"frames":20000,"checkpoints":335,"mismatched":1,
"first_divergent_frame":1801,"checksum":"7899bb1ad171ac0f"}
$ echo $?
90
```
`verify` needs **no display**. A five-minute session is a 2 kB text file. A bug report is
a diff, not a video. And because the simulation is the only thing being replayed, the
whole game is testable by a machine that cannot see.
That holds because of four rules the synced half obeys, and the layout enforces:
| rule | why |
|---|---|
| fixed 1/60 s steps, counted in integers | no frame-rate coupling; `GetFrameTime` is never read by the sim |
| all randomness from a seeded PRNG **inside** the world state | a random decision replays like any other state |
| no FFI reachable from the sim | the C boundary is where determinism dies |
| the view may read the world, never write it | everything it reacts to arrives as an **event** the sim emitted |
**Scope, stated honestly:** this reproduces a run of *the same binary on the same
platform*. Spring needs [STREFLOP](https://springrts.com/wiki/Asynchronous_Simulation) —
FPU flags plus deterministic replacements for libm — to get bit-equality across
different CPUs and operating systems. ressort does not do that yet, so it is a
same-binary guarantee, not a cross-machine one.
---
## The game: Demolition Man, Stage 1

**1996 · Los Angeles · the Wasteland siege.** Sgt. John Spartan fast-ropes from a Chinook
onto a burning tower held by Simon Phoenix. Free four hostages, fight down through three
floors, ride the cable across the fire, and take the roof.
Built to the mechanics of the [SNES original](https://en.wikipedia.org/wiki/Demolition_Man_(video_game))
(Alexandria / Acclaim, 1995):
- **Fast-rope entry.** The stage opens on a cable under a helicopter. Jump to cut loose.
- **Eight-direction fire.** The mechanic the port spent a whole button row on.
- **That jump.** No direction held → a high leap straight up. A direction held → a low,
fixed-length hop. Almost no mid-air steering. It is stiff on purpose; the original's
jump is the first thing every review mentions.
- **Vertical level design.** *"You'll barely take more than a few steps on flat ground"* —
ladders, one-way ledges, drop-through, a zipline over a fire pit.
- **Enemies respawn constantly**, from nests seeded through the level.
- **Contact damage**, limited health, few lives, limited continues.
- **Pistol / shotgun / magnum / grenades**, health and ammo pickups, a 1UP off the
optional tower route.
- **Hostages unlock the exit.** All four, or the roof door stays red.
- **A Phoenix teaser.** He is not the stage boss; he is the taunt. Hurt him and he goes
over the side. He always does.
The look: dark, dreary, purple moody lighting, fire underlighting everything, scanlines.

### Every sprite in the game is text
There is no PNG, no atlas and no binary asset in this repository, and the game loads
no image at runtime. A sprite is rows of characters indexing a palette; `.` is
transparent. (There *is* an image reader — in the `sprite import` tool below, which
transcribes a PNG into this format at authoring time. What gets committed is the text.)
```
func art_spartan_idle() (s) {
r := []string{}
r = append(r, "....3333....")
r = append(r, "...311113...")
r = append(r, "..47777764..")
r = append(r, ".4477777744.")
r = append(r, "...44..44...")
r = append(r, "..000..000..")
s = spr_anchor(spr(r), 6, 18)
}
```
The art diffs in a pull request the way the code does, and an agent can author it.
### And most of it is derived — `bin/ressort`
Two binaries come out of this repository. **`ressort` is the engine's sprite
toolchain**; `demoman` is one game built on the engine, and a stage of Demolition
Man has no business shipping an art pipeline. The tool knows about `.spr`, `.sil`,
`.rig` and `.anm` files and nothing whatsoever about any particular game.
Authoring a sprite has three costs, and they are wildly uneven: the **silhouette**
is cheap and it is the creative part; the **shading** is about 70% of the labour;
the **variants and animation frames** multiply everything above. So the engine
automates the second and the third.

That drone is one 12-line drawing of `#`, `@` and `.`, plus four directives. The
outline, the light ramp down from the top surface, the rim on the lit edge and the
occlusion underneath are computed. Swap the word `metal` for `wood` and it is a
wooden drone.
```console
$ ./bin/ressort sprite shade game/sil/drone.sil
silhouette forged
.....###..###..... .....000..000.....
....##########.... ....0999009990....
...##@@@##@@@##... ...09ccc99ccc90...
..###@@@##@@@###.. ..09jcccjjcccj90..
.####@@@##@@@####. .09j7ccc77ccc7j90.
.################. .0j788888888887j0.
{"name":"drone","w":18,"h":12,"anchor":"8,12","palette":[0,7,8,9,12,19],"sum":"201252dcc3a03b55"}
```
Animation is where a sprite budget actually dies, so no frame asks for new art
either — a walk cycle is the legs sheared out of the hip, the torso leaning the
other way and the body a pixel shorter, all derived from the idle pose:
```console
$ ./bin/ressort sprite anim spartan_idle --kind walk --frames 4
$ ./bin/ressort sprite variant gunner_idle --from bc --to hi # recolour, zero art
$ ./bin/ressort sprite reshade gunner_idle --mat enemy # grade the shader against the hand-drawn original
$ ./bin/ressort sprite show medkit --src # print it as MFL, ready to paste
```

### Art from outside: `sprite import`
The forge derives SNES-scale art. It has nothing useful to say about a 100×144
character with a chest rig and a camo pattern — at that resolution the shading
*is* the drawing. So for art that comes from an artist, a generator or a
reference, the tool is not a generator but a **transcriber**.

```console
$ ./bin/ressort sprite import ref.png --name spetsnaz --native 100 --out spetsnaz.src
{"ok":true,"source":"318x318","detected_scale":0,"native":"100x144","opaque_px":5789,
"source_colors_5bit":274,"palette":32,"rms_error":7.87,"chars":14400}
{"sprite":"spetsnaz","sum":"..."}
{"wrote":"spetsnaz.src","bytes":65637}
```
raylib decodes the PNG (`LoadImage` is CPU-side, so the verb stays headless), a
5-bit histogram and a median cut take it to the 32 colours the text format can
address, and the output is `pal_spetsnaz()` + `art_spetsnaz()` as ordinary MFL.
**The image is never committed.** The artifact is the transcription — text, that
diffs, that you own — which is the same claim the rest of the engine makes.
It reports what the cut cost rather than asking you to trust it (`rms_error`),
and `--compare out.png` puts the original next to the transcription at matching
size. Integer upscales are detected by boundary contrast; art that was scaled by
some non-integer factor has no grid to find, and the importer says so
(`detected_scale: 0`) instead of guessing — `--native WxH` area-resamples it.
### A sentence in, a sprite out
The same split, one step earlier. A human does not operate this toolchain — an
agent does, on their behalf — so the request goes in as a sentence and comes
back as a *well-posed authoring task*:
```console
$ ressort proj init --name wasteland --canvas 24x40
$ ressort sprite ask "a gunman in a long coat, side view, pistol held low" --name gunman
{"task":"draw","canvas":"24x40","anchor":"12,40","write":["assets/gunman.sil","assets/gunman.rig"],
"then":"ressort sprite check gunman"}
…the zone grammar, the five rules the checker enforces, the house skeleton…
```
The agent answers with the **drawing and the rig together**, because the moment
you draw a figure is the moment you know where its legs are. Then one verb
grades the whole thing:
```console
$ ressort sprite check gunman
{"ok":false,"problems":[
{"what":"canvas","where":"16x34 vs the project's 24x40","fix":"redraw on the project canvas, or change `canvas` in ressort.proj"},
{"what":"anchor","where":"the lowest pixel is row 33, the anchor is 40","fix":"move the figure so its feet land on the anchor row"}]}
$ echo $?
90
```
That is the real output of the first attempt at the gunman above — mine. Every
problem carries a **fix written in the vocabulary of the format**, because
"coverage 97%" makes an agent guess and "add a second box with the same pivot
as arm_front" makes it converge in one round.
What it checks is measurable and nothing else: one connected mass (a floating
pixel is always a mistake), feet on the anchor row, a gap between the legs
(two legs sharing a column cannot be rigged apart), rig coverage, and pose
survival. What it will not do is tell you it looks like a gunman — and where
it cannot measure, it declines rather than guessing. Below ~200 pixels it stops
running the pose check at all and says so, because at that size a rotated
two-pixel leg genuinely loses pixels and reporting physics as a defect teaches
the caller to ignore the checker.
**`ressort.proj` is what makes a set cohere.** Canvas, anchor, light direction,
materials and the house skeleton live in one file every request inherits — so
`ask "a gunman"` and `ask "a medic"` come back as siblings rather than
strangers. Style drift is what makes generated art look generated, and most of
it is mechanical.
### Three verbs for the agent driving it
```console
$ ressort status
{"project":"wasteland","assets":"assets","canvas":"24x40","count":2}
{"name":"medic","files":"sil spr rig ","state":"ok","coverage":"100%","depth":2,
"asked":"a field medic, side view, satchel on the hip, no weapon",
"next":"ressort sprite anim assets/medic.spr --rig assets/medic.rig --kind walk"}
{"name":"gunman","files":"sil spr rig ","state":"ok","coverage":"100%","depth":2,…}
```
**`status`** is the whole project in a few hundred tokens: what exists, what
state it is in — `no rig`, `uncovered`, `flat rig`, `stale spr`, `stale anm` —
and the *one command* that moves each asset forward. Staleness is real mtime
comparison, so an `.anm` exported before its rig changed is reported before
anyone wonders why the animation looks wrong.
**`sprite compare a b c`** lays the tone maps out side by side with the facts
underneath, so a candidate can be chosen without opening four files.
`--contact out.png` writes a strip for the human.
**`sprite critique <name> --blind`** is the only honest answer to *does it look
like a gunman*. It prints the sprite with the request, the name and the palette
withheld, and asks one question: **name what you see, in five words or fewer.**
A cold reader answers; the driver compares. The tool's entire contribution is
the withholding — and `ask` records the request in a sidecar precisely so it can
be kept back until after the answer.
### Animating it: the tool asks the agent
*The long version of this section, with the failed first attempt beside the fixed one, is in
the [case study](https://hart.intrane.fr/a/javimosch/machin-ressort-sprite-tool).*

Those eight frames are one drawing. Nothing was redrawn and nothing was
generated — the legs, arms and head were *posed*.
To swing a leg you must first know which pixels are the leg. That is a
judgement, not a measurement, and no amount of code performs one. So the tool
does not try. It does what a tool can do — measure the sprite, state the format,
check the answer, run the deformation deterministically — and asks the agent
driving it for the one thing only reasoning supplies:
```console
$ ./bin/ressort sprite brief spetsnaz.spr --for walk
{"task":"rig","sprite":"spetsnaz","size":"64x92","anchor":"32,92","for":"walk"}
THE SPRITE as tone — one character per pixel, dark to light …
31 .:::.---==+++++--:---.:-.-:-=::::-
32 .-:.:--==-++++=-:-:-::.:.:----:.::
…
YOUR TASK — write a .rig file naming the moving parts of this sprite.
part <name> x=<x0>,<x1> y=<y0>,<y1> pivot=<px>,<py> z=<order>
```
The agent replies with a file:
```
rig spetsnaz 64x92
part torso x=4,44 y=18,56 pivot=26,56 z=3
part head x=18,40 y=0,20 pivot=29,20 z=4 parent=torso
part arm_front_hand x=17,31 y=38,52 pivot=24,41 z=6 parent=torso
part arm_front_gun x=31,52 y=36,56 pivot=24,41 z=6 parent=torso
part leg_front x=26,50 y=52,92 pivot=33,54 z=5
…
```
`parent=` makes the rig a small skeleton, and it is what separates a walk (each
limb on its own) from a recoil or a collapse (the hips lead, the shoulders
arrive late). A child inherits everything done to its parent and states only
what it adds. The legs stay **roots** on purpose — parented to the torso they
would lift off the ground every time the body leans.

That fall is not a chain at all: a body going over backwards rotates *entirely*,
about a point on the ground, so the poser carries one transform outside every
chain — a virtual root the whole sprite hangs from. The parts then only supply
what the body does *while* falling: the head lagging, the knees giving, the arms
trailing.
and the tool grades it, because a plausible answer is not the same as a correct
one:
```console
$ ./bin/ressort sprite rig spetsnaz.spr --rig spetsnaz.rig
{"ok":true,"parts":9,"covered":2327,"opaque":2327,"coverage_pct":100,"first_uncovered_row":-1}
$ echo $? # 90 if a part forgot pixels — they vanish the moment it poses
0
```
Then `sprite anim … --kind walk|idle|aim|recoil|death` poses it. Same rig in,
same frames out, same checksums — which is what makes an agent's answer
*reviewable* rather than merely plausible.
The interesting failure is instructive: a rectangle cannot isolate a rifle held
diagonally across a chest, and a box that swallows the chest swings the chest.
The fix is not a smarter tool, it is a better answer — several boxes sharing a
role prefix and a pivot move as one rigid part. The first rig hollowed out his
vest; the second, three boxes for the near arm, did not. That iteration is the
loop working, and it cost one command each way.
### Searching for a motion instead of writing one
Every scorecard above ends the same way: *the motions are canned, one set of
curves for every character*. The way out is not a better curve. It is to stop
writing curves and let a search find them — which means the motion has to
become data.

```console
$ ressort-evolve walk assets/gunman.spr assets/gunman.rig --out assets/gunman-walk.mot
{"incumbent":{"fitness":9.19,"keep":0.997,"ground":0.938,"stride":0.071,"flow":0.767,"poise":1}}
{"champion":{"fitness":10.27,"keep":0.991,"ground":0.917,"stride":0.571,"flow":0.861,"poise":0.913}}
{"verdict":"champion","margin":1.07}
```
Three pieces, and the split is the point:
| | |
|---|---|
| the **net** is the search mechanism | [tinybrain](https://github.com/javimosch/tinybrain), a tiny MFL neural net evolved against a fitness closure |
| the **table** is the artifact | a `.mot` file: integers, text, diffable, checksummable |
| the **engine** learns about neither | it replays a table, exactly as it always did |
Inference at runtime would have made every ressort binary depend on a neural
network to draw a man walking. Baking the champion onto the frame grid costs
nothing and keeps the claim the whole repo rests on: **the asset is text you
can read.** `bin/ressort-evolve` only builds when tinybrain is present; the
engine never needs it.
Angles in a `.mot` are integer ten-thousandths of a radian, because a text
artifact full of `0.282842712475` round-trips at whatever precision the float
printer feels like. And `mot_from_canned` bakes the hand-written curves into
the same format, so **a baked default replays frame-for-frame identical to the
code that wrote it** — the bridge that makes a searched motion trustworthy.
#### Knees, and asking a human
Two things the search could not fix, because they were not the search's fault.
**A limb of one box can only swing like a pendulum**, which is exactly why a
walk read as a march. Limbs split at the joint now — `leg_front_lower` with
`parent=leg_front` and its pivot on the knee — and the shin trails the thigh
through the recovery half of the stride. Joints bend **one way**: the negative
half of the sine is discarded rather than mirrored, because a knee that
hyperextends looks worse than no knee at all. `sprite check` reports a lower
limb that does not hang off its upper, since a shin pivoting at the hip swings
the whole leg twice.

**And the fitness function still cannot tell whether anyone likes it.** So ask:
```sh
ressort-taste assets/gunman.spr assets/gunman.rig --pairs 20
```
Two candidates loop side by side, same scale, same ground line. `←`/`→` picks,
`SPACE` replays, `1`–`5` say what was wrong with the loser, `S` skips. One
keypress records and loads the next pair — about two seconds a judgement.
A window is the right answer *here and nowhere else in this toolchain*: the
agent-facing verbs are headless because an agent reads text; this one opens a
window because a person watches motion. Both follow from who is answering.
#### What twenty judgements taught the tool
The first version jittered one table with a per-role gain, and the answer came
back: *the options were rather similar to my eye*. The data agreed — and said
something sharper. Across twenty pairs the differences were dominated by a
single quantised measure, and the picks ran **13 to 7 for whichever side was on
the left**. A left-side bias is what a person does when the content cannot
decide it for them, so those twenty judgements were mostly about position.
Three changes, in order of how much they mattered:
1. **A difference floor.** The tool now measures how different two candidates
*look* — the share of pixels that differ, frame for frame — and never asks
about a pair below `--min-diff` (0.12). Pairs went from ~0.05 to a mean of
0.23. This one is a property of the asker, not the generator, and it would
have fixed the session on its own.
2. **Named style axes instead of noise.** A candidate is now a point on six
coarse axes — `amp knee lag arms bob lean` — each at levels far enough apart
to see. Two candidates differing by one level on one axis differ in a way you
could *describe out loud*, which is the same property that makes the
resulting data worth fitting a model to.
3. **Independent lineages.** `ressort-evolve --runs 3 --out-dir lin` gives three
champions from different local optima; `ressort-taste --bases a.mot,b.mot,…`
lets them compete. Cross-lineage pairs differ by ~0.38 — a different
question entirely from "which of these two cousins".
And the bug all that exposed: with a flat angle cap, **the search had been free
to lean a torso as far as it swings a knee** — and it did, so every champion
walked around tilted. Joints have per-role limits now (`role_limit`), applied in
both the styler and the search, so the two cannot disagree about what a body can
do. Champions came out upright *and* scored better: 10.94–11.16 against 10.03–10.22.
#### And what the second session taught it
Twelve judgements, tagged `bouncy` ten times and `legs` eleven. Both are real
signal — a 2.5× bob and a two-frame shin lag are things nobody wants, and the
style axes were retuned on the spot to stop spending questions on them.
But the report caught something worse first: **one candidate was the loser in
ten of the twelve pairs.** My pair chooser anchored on a candidate and matched
it against everything available, so twelve clicks answered one question twelve
times instead of twelve questions once. Pairs are now capped at **two
appearances per candidate** and prefer a **one-axis difference**, because a
single-axis difference is the only kind whose verdict can be attributed to
anything.
```console
$ ressort-taste report taste.pref
{"axis":"bob","winner_higher":2,"winner_lower":8,"reads_as":"you prefer LESS bob"}
{"axis":"arms","winner_higher":2,"winner_lower":9,"reads_as":"you prefer LESS arms"}
{"warning":"one candidate appears in 12 judgements — the pairs did not spread,
so this tally is a verdict on THAT candidate rather than a taste."}
```
The warning checks the **invariant**, not a percentage: pairs are chosen so no
candidate is used more than twice, so five appearances means the spread failed
and the tally is a verdict on one creature. A readout that cannot tell you it is
wrong is worse than no readout.
It writes a `taste.pref` file recording the **seed**, not the motion — a
candidate is deterministic from (base, seed), so every judgement stays
reproducible and the file stays small — plus a feature vector per candidate
(`keep ground stride flow poise leg_amp knee_amp arm_amp bob lean antiphase`)
plus the **style** it was built from and the measured **difference** to its
opponent — so a model can be fitted over the axes a person can name, and can
weight a judgement by how easy it was to make.
Those are the dimensions a person can *name*, which is what will make a fitted
taste model readable back to them: *you reward long strides and bent knees, you
punish arm swing.* `--auto` answers by fitness instead of by hand, which is how
the pipeline is tested without a human.
#### Fitting the taste, and what it refused to claim
Fifteen clean judgements later — no spread warning — the readout was coherent:
**less hip swing, more knee, more arms.** Which contradicts the hand-written
fitness, where `stride` pays 2.5 and stride comes mostly from the hip. That
disagreement is the entire reason to fit a model rather than keep tuning
weights by hand.
```console
$ ressort-evolve fit taste3.pref --out taste.json
{"feature":"leg_amp", "weight":-0.35,"mean_winner_minus_loser":-0.109,"reads_as":"wants LESS leg_amp","status":"confirmed"}
{"feature":"knee_amp","weight":+0.36,"mean_winner_minus_loser":+0.085,"reads_as":"wants MORE knee_amp","status":"confirmed"}
{"feature":"arm_amp", "weight":+0.99,"mean_winner_minus_loser":+0.080,"reads_as":"wants MORE arm_amp","status":"confirmed"}
{"warning":"15 judgements for 6 weights — thin. Four per weight (24) before trusting a sign."}
$ ressort-evolve walk assets/gunman.spr assets/gunman.rig --taste taste.json
{"taste":"taste.json","weight":2,"guardrail":"engineered terms at half weight"}
{"verdict":"champion","margin":1.43}
```
Pairwise preferences become a scorer the way Bradley and Terry did it: train on
the **difference** between the winner's features and the loser's, with the
mirrored row as its own negative so the model cannot learn a side.
Three things it does that matter more than the fit itself:
- **It only learns what you varied.** The first version fitted all eleven
features to fifteen judgements and came back *"wants MORE leg_amp"* while
every winner had less — an unidentified system stating a confident lie. It
now fits the six axes a style actually moves; the rest are measured, never
chosen, and stay on as a guardrail.
- **It prints the raw evidence next to every weight** and marks a sign that
disagrees with it `UNSTABLE`. A readout that cannot tell you it is riding
noise is worse than no readout.
- **A learned taste is only valid near the data.** Every motion anyone rated was
already upright and in one piece, so the model has no opinion about a body
flying apart — the engineered terms stay underneath at half weight. The human
decides what is *good*; the measurements decide what is *admissible*.
#### When the human was right and the metric was wrong
The first taste-driven champion came back and the verdict was *"feels like it
walks backwards, and worse than the ones I picked."* Both halves were true, and
both were mine.
**Walking backwards** was a measurement that did not exist. `keep`, `ground`,
`stride`, `flow` and `poise` all score a cycle played in reverse **identically**
— nothing said which way a planted foot should travel. `tread` does: while a
foot is down it must move backward relative to the direction of travel. It is
asserted against a reversed walk, because that is the hole it exists to close.
**Leaning backwards** came from a feature that was an *absolute value*. `lean`
measured |mean torso angle|, so neither the model nor any term could tell a
forward lean from a backward one — "wants LESS lean" could only mean "less of
either", and the search leaned the body back to the joint limit while
satisfying it. It is signed now, with an `upright` term beside it.
And the weight that drove it there: `lean` was fitted at **−1.30 from a mean
difference of −0.008** — noise with authority. Weights are now **shrunk by how
consistently the winners actually differed on that axis**, so a feature the
judgements split evenly on lands near zero whatever the fit says. The taste's
contribution to the score is bounded too: outside the range anyone rated, a
linear model extrapolates forever and a search will follow it there.
Then the part worth keeping:
```console
$ ressort-taste winner taste3.pref assets/gunman.spr assets/gunman.rig --out picked.mot
{"style":"110201","record":"2-0","stride":0.071,"tread":0.43,"poise":1,"upright":1}
```
A candidate is deterministic from its style, and the style is in the file — so
**the thing a person picked can simply be handed back as a `.mot`**, no search
and no model involved. And it looks better than the champion, while scoring
*lower* on `stride` and `tread`, the two terms the fitness pays most for. Which
means the weighting is still wrong and the eye is still ahead of it. That is
the honest state of this: the loop is real, the measurements are improving, and
the human is still the one who can tell.
#### The eye was right twice more
*"Walk is much better but still got the walk backwards feel. The shoot
animation also needs rework."* Two more defects that every number in the repo
had signed off on.
**A pendulum is not a walk.** Both legs swung as `sin`, which means the foot
moves fastest at the bottom of the arc — exactly when it is planted and should
be moving at a constant backward rate. `tread` only asks whether the planted
foot travels backward *on average*, and it does; what it cannot see is the
**slide**, the foot skating forward through the middle of its own stance. That
slide is the entire "walks backwards" sensation, and it survived the term
written to catch it.
A real gait is not one curve, it is two glued together: **stance**, where the
foot is fixed to the ground and the body passes over it, and **swing**, where
the leg is in the air and returns. Stance is the longer of the two — `STANCE()`
is 0.62 here — so `gait_thigh` is a sawtooth, linear through stance and faster
on the way back, with `gait_knee` bending only during swing so the toe clears.
And the sign was **inverted**: a positive thigh angle puts the foot *behind* the
hip, so every "forward" reach in the old curves was a step to the rear. That was
found by writing the forward kinematics down — `mot_foot` rotates the shin about
the knee and then the thigh about the hip, and `mot_gait` reports which foot is
lower and which way it is travelling. Once a foot had coordinates, the trace
answered in one line what six fitness terms could not.
**The shot had the same class of bug.** `aim` raises the weapon with a negative
angle and `recoil` was pushing with a positive one, so the muzzle kicked *into
the ground*. On top of that the impulse decayed from the first frame, so the
peak lasted one frame at 16 fps — sixty milliseconds, right for a rifle and
invisible on a screen — and eight degrees of torso lean tore the neck open at
40 px tall. It now holds for a frame, decays with a slight **undershoot** before
returning to rest (the shooter pulling down out of the muzzle rise), and spends
its amplitude on the arms where a recoil is actually visible.

One test had to go: *"a cycle comes back round"* asserted that the half-way
frame of a walk is pixel-identical to the first. That is true only of a
sinusoid, where both legs sweep the same shape and swapping them changes
nothing — **the test was the march, written down and passing**. It now asserts
what should actually hold, that the cycle is periodic in phase however finely it
is sampled, and that half a cycle is *not* a mirror.
The three `.mot` files in `assets/` were baked before this and encode the old
pendulum, including the human's picks — those judgements were made against a
gait that slid. `ressort-evolve` and `ressort-taste` both derive their base from
the canned curves at run time, so a fresh session starts from the fixed gait;
the stored tables would need re-earning.
### A hundred of them, generated
Everything above starts from a drawing somebody made — an import from a
reference, a silhouette typed into a source file. A gallery cannot work that
way: a hundred characters is a hundred drawings, and nobody is drawing them.
```sh
./bin/ressort-gallery # A/D or arrows to cycle, W/S by ten
./bin/ressort-gallery --sheet out.png --count 24
./bin/ressort-gallery --shapes --sheet s.png # one of each kind, same frame
./bin/ressort-gallery --list --count 100 # for the agent, not the eye
```

So the proportions come first and the drawing comes second. A `Body` is two
dozen numbers drawn from a seed, and **both the silhouette and the rig are
emitted from those same numbers**. That is the only reason the joints land on
the limbs. The usual way round — draw a figure, then write a rig that guesses
where its knee is — works until the proportions change, and then never works
again. Here the knee row is an input to both.
Which makes the rig checkable by the rules that already exist. `rig_coverage`
does not care that a generated body wrote its own rig, and the test asserts the
worst of sixty seeds covers **100%** of its own drawing, because a generator
that is right 95% of the time ships five broken figures in a gallery of a
hundred.
The seed also picks how that body *runs*, and proportion drives it before the
seed does — a heavy short-legged frame does not run like a tall light one. The
`Style` axes the taste sessions produced (`amp`, `knee`, `arms`, `bob`, `lean`)
turned out to be exactly the knobs a character generator wants, so a runner is
`mot_restyle(canned_run, body_style(b))` and nothing new was needed.

#### Human scale
*"Legs are often too long, doesn't fit human scales."* The first thing the
measurement said was that the **mean was already right** — 49.1% of height,
where a person is about 50%. The spread was 38% to 61%. Nobody sees a mean.
```console
$ ressort-gallery --list --count 200 # before
leg % of height min/mean/max: (38, 49.1, 61) human ~47-52
heads tall min/mean/max: (4.8, 6.1, 7.8) human ~7.5
```
So the landmarks are the figure-drawing canon now, as fractions of **total
height**, with human variation rolled around each — crotch at half the height,
knee three quarters down, elbow at 0.375, wrist at 0.48. Every archetype is
free to move them and every one is clamped on the way out, because a shape
language is a set of *people*: squat is a short-legged person, not a corgi.
```console
$ ressort-gallery --list --count 200 # after
leg % of height min/mean/max: (44, 49.6, 55)
heads tall min/mean/max: (5.3, 7.0, 8.2)
knee % from top min/mean/max: (70, 72.3, 75)
```
The arms were the real tell: elbow and wrist were fractions of the **trunk**,
so a long-backed body grew long arms for no reason connected to how tall it
was. That is asserted now — the elbow stays between 33% and 45% of height
whatever the trunk is doing.
Two things this exposed, both worth naming rather than quietly fixing:
- **Fixing the vertical landmarks produced a correctly proportioned skeleton
inside a barrel.** Girth came from a limb-thickness unit unrelated to the
canon, so the widths had to move onto the same scale or the figure is two
scales arguing.
- **Strict canon at forty pixels is a three-pixel thigh**, and a three-pixel
limb outlined on both sides is a one-pixel line — anatomically right and
visually a stick. The widths carry a deliberate concession above canon; the
vertical landmarks stay honest. It is in one place and labelled.
And one thing that was never a proportion problem at all: the limbs looked
spindly in dark navy and correct in red, on the same body. **A near-black
contact sheet was eating them.** The sheet background is a mid-tone now — the
figures had been the right width for two rounds of tuning.
#### Shape language
Size and colour are not variety. A hundred upright figures of slightly
different heights is one character rendered a hundred times, and widening the
random ranges makes that worse rather than better — it adds noise, not kinds.
A *kind* of body is a set of proportions that move **together**. Barrel-chested
means a deep chest and a thick waist and short limbs and a low bounce, all at
once; any one of them alone just looks like a mistake. So the archetype is
rolled first and then edits several numbers at the same time, and the gait
follows for free because the run already reads proportion.

```console
$ ressort-gallery --list --count 40 | jq -r .shape | sort | uniq -c
14 even 8 hunched 7 lanky 6 long-armed 4 barrel-chested 1 squat
```
Two of these needed the drawing to learn something new:
- **A hunch is a rolled back *and* a head in front of the spine.** The first
attempt widened the top of the trunk symmetrically, which is not a hunched
man, it is a wider man. The bulge is on the back only now.
- **Long-armed is the one archetype width cannot express** — it needs the arm
to keep going after the trunk has stopped. Which made it the one archetype
with a structural signature worth asserting: a hand below the hip line. The
test caught the hunch quietly claiming the same signature (it was adding two
rows of arm on top of a base roll already at the limit), and that is now
clamped so the long-armed body keeps the only thing that makes it itself.
And one defect the archetypes only made visible: **the head was butt-joined to
the torso**, so any lean opened a seam at the neck. The hand-written gunman rig
overlaps head and torso by a row and always had; the generated one now does
too. It read as a floating head long before the archetypes existed — a wider
range of bodies just made it impossible to keep ignoring.
**Running is not a fast walk.** A walk always has a foot on the ground; a run
does not. Contact drops from 62% of the cycle to 34%, which leaves two windows
where neither foot is planted and the whole figure — legs included — is in the
air. That flight phase is the entire read, and it is the one thing the test
asserts about the gait.
Three things the numbers passed and the eye did not, each caught by rendering a
sheet and looking at it:
- **2px sticks on an 8px body.** The trunk came from one ratio and the limbs
from another. Limb thickness is a *limb* fraction now, and every width —
hips, chest, waist, arms — derives from it.
- **A shin folded ninety degrees** reads as a broken leg, not a stride.
- **Arms as wide as the chest, in a neighbouring material**, made the upper
body one featureless slab. The material list is ordered dark-to-light and
limbs are picked a fixed distance along it from the torso, so the contrast is
structural rather than lucky.
### A vehicle, and what it broke
The next POC — a desert transport helicopter — is the first asset this engine
cannot generate and the first that is not a person. Building one drawing found
five things the toolchain had quietly assumed about characters.
```sh
./bin/ressort sprite shade assets/helo.sil --out assets/helo.spr --pal desert
./bin/ressort sprite rig assets/helo.spr --rig assets/helo.rig
./bin/ressort sprite tilt assets/helo.spr --rig assets/helo.rig --steps 9 --range 45 --sheet out.png
```

**An airframe is not a skeleton.** Almost nothing on a helicopter moves
relative to anything else, so the question is not which joints bend but what
the whole thing rotates *about* — the rotor head, because that is where thrust
is applied. Pivot at the centre of the sprite and dropping the nose swings the
tail the wrong way; it reads as a boat turning rather than a helicopter
tipping. And the poser already knew how: `rig_pose_g`'s virtual root, built so
a dying man could topple about his heels, is the same transform with a
different pivot and a better reason. `sprite tilt` bakes the ladder at load, so
the rasteriser runs nine times instead of sixty times a second.
The rig has three parts, and the reason is not anatomy: **a part whose
rectangle the hull also covers is redrawn by the hull on top of it**, so
anything that must move alone needs an exclusive band. That is what put the
tail rotor in its own ten columns, not the reference art.
Four things that had never come up before:
- **`.sil` had no comment syntax.** Everything that is not a directive is art,
so three lines of prose became 2,938 stray pixels. It cannot be `#` — that is
the commonest art character in the format — so it is `;`.
- **A hand-authored `.sil` could not become a `.spr` at all.** The only path was
passing `check`, which gates on the project's *character* canvas. The one
asset the engine cannot generate was also the one it could not save;
`shade --out` closes it.
- **Nothing was wired to the palette indices except the presets** — the forge
had claimed that since the beginning and nothing had ever tested it. `sand`
was reading as skin because slot 1 **is** skin. `--pal desert` repaints all
twenty slots and every material still works, because a material names a slot,
not a colour.
- **The sheet stands sprites on their anchor**, which is right for a character
whose anchor is its feet and drops a helicopter off the bottom of the page.
An anchor in the top half is a hanging point, not a footprint.
Two more that the eye caught and no check would have:
- **Depth shading is for limbs.** Every interior pixel of a fuselage is equally
deep, so a three-step ramp painted the entire airframe its darkest step and
the camo had nowhere left to go. Vehicle materials use two-step ramps and let
the *drawing* carry the panels.
- **Dithered camo is not camo, it is noise** — it swallowed the panel lines
whole. Solid patches with a ragged edge read as paint.
The character checks still fire on it (`120x52 vs the project's 24x40`, *"move
the figure so its feet land on the anchor row"*). They are correct about a
person and wrong about a helicopter, and `ressort.proj` has one canvas
contract. A second one is the honest fix and is not written yet.
## Dusty Helo — M0
The next POC: a transport helicopter over a collapsed, dust-blown world. This
milestone is the flight model and nothing else — no cargo, no villages, no
weather — because a design document cannot tell you whether a thing is good to
fly.
```sh
./bin/dusty # A/D tilt · SPACE climb · CTRL descend · R reset
./bin/dusty --bot --shot out.png --at 300
```

**The whole control scheme is one rotation.** Thrust points along the
airframe's own up, so `a = T·(sin θ, −cos θ) + (0, g) − k·|v|·v` is the entire
model. There is no "move" input: tilt is the only source of horizontal thrust,
and tilting makes the vertical component `T·cos θ`, which is less than it was.
Leaning to go somewhere costs altitude, and **no line of code says so** — it
falls out of the geometry. It is also why the pitch pivot had to be the rotor
head: thrust is applied where the sprite turns.
Three things measurement decided and the eye could not:
- **Hands-off has to mean level**, and quadratic drag alone cannot deliver it.
Neutral collective exactly cancels gravity, so at a physically tidy drag
value the aircraft coasted *upward* for ten seconds after the stick was
released — decaying as 1/t, killing a fast climb quickly and then leaving a
2 m/s drift hanging around. A linear damping term on top decays
exponentially, which is what a rotor's own inflow does anyway.
- **The zoom curve had a second constraint the design never saw.** Altitude
drives zoom so that descending *is* zooming in — 24 px/m on the deck where a
generated humanoid is readable, ~2 px/m at cruise where a 10 km leg is
seventeen screens instead of three thousand. But to see the ground at all,
the view has to reach `alt` metres below the aircraft, and the readability
curve alone put the desert eighty metres past the bottom edge. Keeping the
world on screen costs zoom at altitude, and that is the right way round to
pay. The test asserts it.
- **The camera bias sign was inverted**, centring the view *above* the
aircraft and pushing the only thing on screen off the bottom of it. Then the
fix needed a floor: on the pad, a bias measured in screen-heights puts the
camera underground.
The flight model is a pure function of `(state, bits, dt)`, so the test suite
flies it without a window — and the same property is what will let a mission be
replayed from a recorded input stream.
Two known gaps, neither an M0 problem. `spr_draw` rasterises with `int()`
coordinates, which is why the world is measured in pixels rather than metres —
and it also means a sprite drawn at less than 1:1 drops columns, so the
**generated humanoids need a smaller native size before they can stand next to
the helicopter**. At 1.8 m they want ~18 px, and the generator makes 34–48.
Related: the helo sprite is 115 px for 11.5 m and a body is 40 px for 1.8 m,
so the two are on scales that differ by 2.2×. Both land in M3, with the people.
### M1 — the load
A crate on a rope, and the rope is the mission.
```sh
./bin/dusty # ENTER hooks up and releases
```

**The cable is a spring that can only pull.** That one asymmetry is the whole
character of a slung load: it goes slack when you drop under it and snatches
when you climb, and a two-sided spring gives neither. Damping uses the closing
speed *along* the cable, so it absorbs a snatch instead of ringing.
And it is coupled **both ways** — the tension that hauls the crate toward the
hook hauls the aircraft toward the crate, scaled by the mass ratio:
```
bx = LOAD_R · f · ux // one line, and it is the entire reason
by = LOAD_R · f · uy // a slung load is interesting to fly
```
One-way coupling would have been easier and would have removed the only thing
that makes this a job rather than a delivery animation. The HUD shows swing as
the **sine** of the angle off vertical, because that is not a decoration: it is
the horizontal error the pallet will land with if you let go now.
The hook is bolted to a rigid body, so it rotates with it — pitch the nose down
and the belly swings aft. Same pivot as the airframe, for the same reason.
Two ways to deliver, exactly as specified, and they are different physics:
- **Set it down.** Lower it until the crate touches at under 4 m/s and the hook
lets go by itself. No timing required, but you have to hold a hover.
- **Drop it.** Release low and settle; the crate free-falls and is judged on
impact speed. Faster, and a swinging load throws the pallet sideways.

The load never touches the helicopter struct — `load_step` returns the force
and the caller applies it, which is what keeps both testable alone. Twenty-three
assertions cover it, including one I wrote backwards: *y grows downward here*,
so hauling the aircraft toward a load hanging below it is a **positive** force.
The test was wrong and the code was right.
The autopilot in `--bot` is a controller rather than a canned key sequence,
and it earned its keep by failing in three instructive ways: it flew for the
marker before hooking up and collected the pallet at frame 120 by the clock,
sixty metres up and half a kilometre from a crate it never touched; it braked
at a fixed range and overshot by 170 m at 50 m/s; and it began its descent
within a hundred metres of the marker, set the pallet down short, then
dutifully picked it up and carried it again.
### In-repo skills
Two processes came out of building the helicopter and the world it flies over,
and neither is helicopter-specific. They live in `.agents/skills/` so the next
agent that opens this repo starts where this one finished:
| Skill | For |
|---|---|
| [`ressort-vehicle-asset`](.agents/skills/ressort-vehicle-asset/SKILL.md) | any rideable that tilts, leans or turns — car, bike, plane, tank |
| [`ressort-side-scroll-world`](.agents/skills/ressort-side-scroll-world/SKILL.md) | units, parallax, camera framing, state-driven zoom |
The vehicle skill is mostly a list of the five places the toolchain assumes it
is drawing a person, and the one decision everything else follows from: **the
pivot is where force is applied** — rotor head, contact patch, quarter-chord,
turret ring. The world skill carries the units rule (the world is in pixels
because `spr_draw` truncates), the zoom-follows-the-controls idea and the
second constraint that nearly sank it, and the parallax minimums.
Both are written as what to do and what it costs, with the failures kept in —
a skill that only lists the happy path teaches nothing the code does not
already say.
#### Seeing it
The gunman and his evolved walk are **in this repository**, so the result is
reproducible rather than described:
```sh
./build.sh
./bin/soldier --mot assets/gunman-walk.mot # drive it: A/D walk, J fire
./bin/soldier # the same character, written curve
# or re-run the search itself — same seed, same champion
./bin/ressort-evolve walk assets/gunman.spr assets/gunman.rig --gens 24 --pop 36
./bin/ressort sprite anim assets/gunman.spr --rig assets/gunman.rig \
--mot assets/gunman-walk.mot --sheet out.png
```
`bin/soldier` takes `--mot` and **nothing else in that program changes** — it
asks for a walk cycle and gets one, indifferent to whether a person wrote it or
a population found it. That indifference is the point of baking a table.

#### What the search taught me about the fitness function
The first champion scored **8.90** and looked like a man hit by a truck: bent
33° forward, permanently. It had found that leaning the whole body swings the
toes a long way (`stride`), loses no pixels (`keep`) and never leaves the
ground line (`ground`). Every term I had written was satisfied.
What was missing had a name once I saw it: **an animation is an oscillation
around the pose that was drawn, not a licence to pick a new one.** So `poise`
measures how much the average frame still overlaps the drawing it came from —
a floor, not a band, because swinging a leg should cost some overlap and
walking around doubled over should not be free.
Before that, a subtler one: `stride` read **zero for every walk ever written**,
including the hand-tuned ones. It averaged the x of the bottom eighth of the
*canvas*, and a posed frame carries padding underneath it, so it was measuring
empty rows. A fitness function that measures the wrong thing gets exactly what
it asks for — twice, in one afternoon.
### Exporting it
Posed frames are **not the same size as each other** — `spr_row_del` moves the anchor, a
topple pads the canvas, a walk does not. Packed naively into a strip the character jitters
against the ground in whatever engine loads it, so every export is built from one box: the
union of all frames *aligned by their anchors*, with one anchor in the metadata.
```console
$ ./bin/ressort sprite anim hero.spr --rig hero.rig --kind walk --frames 8 \
--out-anm walk.anm --out-png walk.png --out-json walk.json
{"kind":"walk","frames":8,"box":"84x112","anchor":"42,102","fps":12,"loop":1}
{"wrote":"walk.anm","format":"anm"} {"wrote":"walk.png","size":"672x112"} {"wrote":"walk.json"}
```
| format | what it is | size here |
|---|---|---|
| `--out-anm` | the **recipe** — sprite, rig, kind, fps/loop, and a checksum per frame | **352 B** |
| `--out-png` + `--out-json` | a strip plus Aseprite-shaped metadata (frames, durations, anchor, tags) — what Godot, Unity, Phaser and LÖVE read | 25 kB + 1.4 kB |
| `--out-src` | every frame as MFL, to compile the poses in rather than pose at boot | 97 kB |
| `--out-spr` | a multi-frame text sprite | 81 kB |
The PNG is written headlessly — the buffer is poked by hand and handed to raylib's
`ExportImage`, no window and no GL context — so the export runs in CI.
And because the recipe is 352 bytes with a digest per frame, an animation can prove itself
the way a playthrough does:
```console
$ ./bin/ressort sprite verify walk.anm
{"verified":true,"anim":"spetsnaz","kind":"walk","frames":8,"box":"84x112"}
$ echo $?
0
```
Change the rig, the sprite or the poser and that exits **90** with the first divergent frame.
Timing belongs to the motion rather than the rig: a walk loops, a death holds its last frame.
### And then you play it

```sh
./bin/soldier # runs on a forged fallback figure
./bin/soldier --spr hero.spr --rig hero.rig # or on your own import
./bin/soldier --bot --shot out.png --at 240 # a screenshot, no hands
```
`A`/`D` walk, `SPACE` jumps, `J` fires, `K` holds an aim, `R` resets. The pose is
picked from the frame sets the rig produced — **the walk cycle is indexed by
distance travelled, not by time**, which is the difference between feet that
walk and feet that skate. Firing drives the recoil pose from its own countdown,
so the animation and the weapon share one clock.
That is the whole integration surface, and it is the point of the demo: five
`rig_anim` calls at startup and the poses exist. A game that would rather not
pose at boot compiles them in with `--out-src` instead — same frames, same
checksums. The demo scales whatever sprite it is given to a fixed world height,
so a 28-row forged figure and a 99-row import both stand the same height next
to the crates. Which are themselves forged, at startup, from twelve lines of
`#` and `%`.
The soldier in that shot is the **fallback**: no third-party art ships in this
repository, so with no `--spr` the demo forges its own figure rather than
refusing to start.
**What this style costs, measured.** One pose at 100×144 is 14,400 characters
against 216 for `spartan_idle`; the art still versions, but nobody reviews that
as a diff. And `spr_draw` emits one `DrawRectangle` per opaque pixel, so:
| sprites on screen | rects/frame | fps |
|---|---|---|
| 8 × (100×144) | 46,312 | 60 |
| 24 × (100×144) | 138,936 | 35 |
| 24 × (64×92) | 55,848 | 60 |
About 60k rectangles a frame is the ceiling on this machine — roughly a dozen
detailed characters. Past that the renderer needs a texture upload rather than
per-pixel rectangles, which stays on the unsynced side of the line and leaves
determinism untouched. `--bench N` is how that table was produced.
Every one of those verbs runs **with no display**, and every sprite carries the
same kind of checksum the simulation does — so generated art is reviewable in a
pull request, and `build.sh test` catches the day the shader changes its mind.
Generated art that cannot be edited by hand is a dependency, not an asset:
`--src` prints the exact MFL the repository already commits, and from there it is
ordinary text you own.
---
## Build and run
```sh
./build.sh # vendors raylib 5.0 (static) if needed → bin/ressort + bin/soldier + bin/demoman
./build.sh test # headless engine test suite, no display needed
./bin/demoman # play
./bin/demoman play --record run.rr # play and record
./bin/demoman replay run.rr # watch it back
./bin/demoman verify run.rr # headless: prove it reproduces (exit 0 / 90)
./bin/demoman sim --frames 9000 # headless: run the attract bot, print JSON
./bin/demoman scene game/level1.rml # validate and summarise a level
./bin/demoman shot out.png --at 600 # render to frame N and screenshot
./bin/demoman guide # JSON description of engine + stage
./bin/demoman art list|show|sheet # this game's own sprites
# …and the toolchain, which is a different program entirely:
./bin/ressort guide # the whole tool surface, as JSON
./bin/ressort sprite shade game/sil/crate.sil # a flat drawing → a shaded sprite
./bin/ressort sprite anim spartan_idle --kind walk
./bin/ressort sprite sheet out.png --sil game/sil/drone.sil
./bin/ressort sprite import ref.png --native 64 --spr hero.spr # any PNG → text
./bin/ressort sprite brief hero.spr --for walk # the rigging task
./bin/ressort sprite rig hero.spr --rig hero.rig # grade the answer
./bin/ressort sprite anim hero.spr --rig hero.rig --kind walk
```
| | |
|---|---|
| move | arrows / WASD |
| aim | hold a direction while firing (8-way) |
| jump | `SPACE` (with a direction = hop, without = leap; `DOWN`+`SPACE` = drop through) |
| fire | `J` or `CTRL` |
| switch weapon | `TAB` |
| grab the cable | `E` |
| restart / quit | `R` / `ESC` |

---
## Layout
```
engine/00_core.src synced scalar math, Vec2, AABB, PRNG, state checksum
engine/10_scene.src synced entities, behaviour bitmask, scene queries
engine/20_tilemap.src synced ASCII tilemaps, swept AABB collision, LOS
engine/25_grid.src synced integer grids, flood-fill routing, sight lines, RLE files
engine/30_sim.src synced the fixed step, events, demo record/replay
engine/40_scenefile.src synced the declarative scene format
engine/50_ffi.src unsynced the entire C boundary (one extern block)
engine/55_audio.src unsynced the sound boundary — banks, voices, streams, speech
engine/56_synth.src unsynced making a noise out of arithmetic: WAV, oscillators, levels
engine/57_ear.src unsynced judging a sound without hearing it
engine/60_view.src unsynced camera, parallax, text sprites, particles, HUD
engine/65_bake.src unsynced sprite rotation baked at load, palette swaps, a draw canvas
engine/70_forge.src unsynced the art forge — shading, recolours, rig, animation
engine/80_import.src unsynced the importer — PNG → palette + text sprite
engine/90_rig.src unsynced .spr files, cutout rigs, the agent brief, exporting
engine/95_cli.src unsynced flags and JSON printers both binaries share
engine/96_ask.src unsynced the project file, the request brief, and the checks
game/10_defs.src synced kinds, state slots, weapons, tuning
game/20_art.src unsynced every sprite, as text
game/30_sim.src synced the rules
game/40_view.src unsynced the frame
game/50_main.src both CLI verbs, the loop, the attract bot
game/level1.rml data the stage
game/sil/*.sil data silhouettes the forge shades
engine/97_motion.src unsynced .mot tables, the bake/replay bridge, and what a motion is worth
tools/sprite.src unsynced bin/ressort — the sprite toolchain's own CLI
evolve/motion.src unsynced bin/ressort-evolve — the motion search (needs tinybrain)
taste/ab.src unsynced bin/ressort-taste — two walks, one keypress
demo/soldier.src unsynced bin/soldier — the pipeline, played (WASD + fire)
tests/engine_test.src synced 700+ headless assertions
```
Modules are concatenated by `machin encode a.src b.src …` — that is the module system.
A consumer game supplies one hook, `game_step(sim, input) → sim`, and the engine calls it
inside the fixed step. Everything else is composition.
## What a second game put back
Everything in `25_grid`, `55_audio`, `56_synth`, `57_ear` and `65_bake` was
written for **[machin-gta](https://github.com/javimosch/machin-gta)** — a
top-down GTA 1 built on this engine, with a generated city, ten vehicles,
synthesised audio and spoken dialogue — and moved here once it was clear none
of it was about cars. That is the second game this engine has carried, and the
first to look straight down; a camera rotation is a good way to find out which
of your abstractions were really about the camera.
The third is
**[machin-ressort-splinter-cell](https://github.com/javimosch/machin-ressort-splinter-cell)**,
which took the engine's rig idea in the opposite direction: instead of baking
poses at load, it computes them every frame from continuous state — a 16-joint
skeleton, forward kinematics to draw it and two-bone IK to place its hands and
feet, with the gait phase advanced by distance travelled so the feet cannot
slide. It needed a pointer, which is why the engine has mouse input now.
- **`25_grid`** — an integer grid, a breadth-first flood from a destination that
everything heading there walks downhill, sight lines, and a run-length JSONL
file format. The flood replaced an axis-following router *twice*, in two
different games, before it was written here. A heuristic — go along one axis
until level with the target, then turn — is optimal on an open grid and a
guess on anything with a dead end in it, and its failure mode is a wall it
cannot see round, which it presses against forever.
- **`55_audio`** — this engine had no sound at all. Banks with aliases, because
playing a handle that is already playing *restarts* it, so an automatic
weapon on one handle is one shot cut off eleven times a second rather than a
burst. Loading is all-or-nothing in two passes: aliasing a sound that failed
to load dereferences a null buffer inside raylib and takes the process with it.
- **`56_synth`** — a WAV writer, band-limited oscillators, noise, envelopes, and
levelling. Generated audio is cheap, deterministic and has nothing to license.
- **`57_ear`** — the reason the rest of it is maintainable. An agent writing a
game cannot hear it, so a sound that cannot be checked by measuring it cannot
be iterated on, and "it probably sounds fine" is the same sentence as "it is
untested".
- **`65_bake`** — the helicopter's pitch ladder, generalised from seventeen rig
poses over ±48° to a plain sprite taken round the full circle, plus the
palette swap that makes ten colours cost one bake and a canvas to compose
sprites in code.
`rs_atan2` and `rs_angdiff` went into the core at the same time. Their absence
had been worked around in three places, each of which was a bug: a chase that
flips direction every tick is an unwrapped angle difference, and reaching for
sine alone loses the quadrant the moment a target is behind you.
## Two bugs this engine found in the language
Both were measured, not guessed, and both are documented at the fix site.
1. **`col_mul` overflowed a `u8` cstruct field.** A 1.5× tint on bright orange wrapped
past 255 and came back green — the fire in the level rendered green until it was
clamped. FFI `u8` fields wrap silently.
2. **MFL emits `*` and `<<` as signed 64-bit C operations, so overflow is undefined
behaviour the optimizer may fold.** The textbook 64-bit FNV round
`h = (h ^ b) * 1099511628211` collapsed to a constant (`INT64_MAX`) at `-O2` — the
state checksum silently stopped discriminating, and two worlds with different RNG
state produced the same digest. The same hazard applied to the xorshift PRNG.
Both were rebuilt as two lanes whose every intermediate stays under 2^45, so no
operation ever leaves the int64 range. A determinism guarantee cannot rest on UB.
## Known limits
- The animation kinds are **canned**. All five now read — `idle` breathes rather than
popping, `aim` anticipates and settles, `walk`/`recoil` chain properly, `death` topples —
but they are one set of curves for every character. A specific character wants per-part
timing the rig cannot express yet.
- **Rectangles are blunt.** Multi-box parts get most of the way; a real cutout tool would
let a part subtract another. And there is no secondary motion — a sling travels rigidly
with the torso rather than lagging behind it.
- The attract bot (`--bot`, and the `sim` verb) is a route-following exerciser, not a
good player. It reliably fast-ropes in, clears three floors, rides the cable and
rescues **all four hostages** — but it does not reliably climb back to the roof and
finish the stage. It exists to exercise the level headlessly and to drive screenshots.
- Determinism is same-binary, same-platform (see above).
- No sound yet.
- One stage.