# Machin Ressort

> Build 2D games on the machin-ressort engine (MFL + raylib) — a Torque2D-style declarative scene/behaviour model over a Spring-style deterministic simulation with record/replay/verify. Use when writing or extending a ressort game, authoring a .rml level, adding a behaviour or a system, drawing with the text-sprite format, or debugging a replay that diverges. Also read this before hashing or doing bit-twiddling in MFL — it documents two overflow traps that silently corrupt results.

- Skill: `javimosch/machin-ressort` (Agent Skill, multi-file: 21 files)
- Install (CLI): `npx skillmds@latest add javimosch/machin-ressort`
- Raw SKILL.md: https://api.skillmd.com/api/skills/javimosch/machin-ressort/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: javimosch (https://skillmd.com/u/javimosch)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/javimosch/machin-ressort

---


# machin-ressort

A 2D engine in MFL at `~/ai/machin-ressort`, plus a Demolition Man stage-1 POC.
Successor to [machin-gum-2d](https://github.com/javimosch/machin-gum-2d) (525 lines,
Vec2/verlet/particles); ressort is the ambitious one.

Read `README.md` first for the why. This is the how.

## The one rule

**Synced code may never touch the view. Unsynced code may never write the world.**

| synced (deterministic, headless, checksummed) | unsynced (raylib) |
|---|---|
| `engine/00_core 10_scene 20_tilemap 30_sim 40_scenefile` | `engine/50_ffi 60_view 70_forge 80_import 90_rig` |
| `game/10_defs 30_sim` | `game/20_art 40_view` |

Synced code must not call: raylib, `now()`, `now_ms()`, `rand_bytes()`, `GetFrameTime()`.
For randomness use `sim_rand(s,n)` / `sim_randf(s)` / `sim_randr(s,lo,hi)` /
`sim_chance(s,pct)` — they thread the PRNG that lives *inside* the world state.

The view reacts to the sim only through **events**: the sim calls
`sim_emit(s, EV_*, x, y, a, i)`, the view drains `s.events` each rendered frame. If you
find yourself wanting to mutate `s` from the view, the answer is a new event.

Break either rule and `demoman verify` stops meaning anything.

## Three binaries

**`bin/ressort` is the engine's sprite toolchain** (`tools/sprite.src`); **`bin/soldier` is
the playable pipeline demo** (`demo/soldier.src` — WASD + fire, runs on a forged fallback
figure when given no `--spr`, so no third-party art ships here); **`bin/demoman` is the
Demolition Man stage** (`game/*.src`). Both are ENGINE + one main. The tool works on
`.spr`/`.sil`/`.rig`/`.anm` files and knows nothing about any game's art table; the game
keeps `demoman art list|show|sheet` for its own sprites. Anything both need (flag parsing,
the JSON printers, `SCREEN_W/H`, `pal_default`) lives in the engine — `game/20_art.src`'s
`dm_palette()` is now a one-line alias over `pal_default()`.

## Build & verify

```sh
./build.sh          # → bin/ressort + bin/demoman   (vendors raylib 5.0 static into vendor/)
./build.sh test     # headless engine suite, exit 0/1

./bin/demoman scene game/level1.rml   # validate a level, JSON out
./bin/demoman sim --frames 9000 --trace --record run.rr
./bin/demoman verify run.rr           # exit 0 reproduced / 90 diverged / 2 bad input
./bin/demoman shot out.png --at 600   # DISPLAY=:0; raylib writes to CWD, not the path's dir
```

`machin encode a.src b.src c.src` concatenates modules — that is the module system.
Order matters only for readability; the whole program is typechecked together, so the
engine can call the game's `game_step` hook by name.

Screenshot gotcha: `TakeScreenshot("docs/x.png")` writes `./x.png` — raylib strips the
directory. Move it afterwards.

Font gotcha: raylib's **default font is ASCII-only**. An em dash, `→` or `·` passed to
`hud_text`/`DrawText` renders as `?`, and you only find out by reading the screenshot
back. Keep anything drawn on screen ASCII; the docs can keep their typography.

## Adding an entity type

1. A `kind` code in `game/10_defs.src`.
2. A `prefab` line in the `.rml`, or an `entity` line for a one-off.
3. A branch in `game/30_sim.src` (a system, or a case in an existing one).
4. A sprite in `game/20_art.src` (forge it first — see below) and a branch in
   `draw_entities`. Add the name to `art_names` / `art_by_name` in `game/40_view.src`
   or it will not show up in `ressort sprite`.

Slots the **engine** owns on every `Ent` — do not repurpose:

| slot | owner | meaning |
|---|---|---|
| `s2` | `tm_move` | 1 = fall through one-way ledges this step |
| `s3` | `sys_physics` | 1 = suspend gravity (rope, ladder, zipline, death) |
| `t0` | `sys_lifetime` | countdown, `BEH_LIFETIME` only — never give the player that bit |

Everything else (`s0 s1 f0..f3 t1`) is the game's. Document which behaviour owns which.

## The scene format (`.rml`)

```
scene name=… gravity=… tile=32 seed=…      # once
set  key=value key=value                    # metadata; NO SPACES in a value
layer name=… depth=0..1 color=r,g,b y= h= kind=0..4
prefab <name> k=v …                         # a reusable row
spawn  <prefab> at=cx,cy [overrides]        # overrides win over the prefab
entity kind=… name=… at=cx,cy hw= hh= hp= beh=a,b,c
tiles
…rows…
end
```

- `at=` is in **cells**; the entity's feet land at that cell's bottom. Standing on a
  floor at row R means `cy = R-1`. `px=x,y` overrides with raw pixels.
- `beh=` names resolve via `beh_by_name`; an unknown name is a **parse error**, not a
  silent drop. So is an unknown prefab and a missing `tiles` block.
- layer `kind`: 0 flat band, 1 skyline, 2 lit windows, 3 smoke, 4 stars.
- tile legend: `.` air, `#` solid, `=` one-way ledge, `H` ladder, `T` ladder top
  (standable + climb-through), `^` fire, `-` zip cable, `,` decor.

**Ladder topology.** A ladder joining an upper floor at row `RU` to a lower floor at row
`RL` is `T` at `(x, RU)` and `H` at `(x, RU+1 … RL-1)`. The actor standing on the lower
floor is then already *inside* the bottom rung, so `UP` grabs it, and `T` replaces the
upper floor tile so the climb passes through. Get this backwards and the ladder dead-ends
into a solid slab.

**Vertical clearance.** An actor `2*hh` px tall cannot fit a gap of `tile` px if
`2*hh >= tile`. With 32 px tiles, `hh=15` (30 px) passes a one-tile gap; `hh=17` (34 px)
does not, and every one-tile corridor becomes an invisible wall. This cost hours.

## Text sprites

```
func art_x() (s) {
    r := []string{}
    r = append(r, "..3333..")     // '.'/' ' transparent, '0'-'9' then 'a'-'v' = palette 0..31
    s = spr_anchor(spr(r), 4, 8)  // anchor (ax, ay) in sprite pixels; feet = (w/2, h)
}
spr_draw(sprite, palette, x, y, pixel_scale, flip, tint)   // flip = -1 mirrors
spr_draw_flat(sprite, color, x, y, scale, flip)            // silhouette (shadows)
```

The palette is a `[]Color`. It **cannot** be a field of an MFL struct (cstructs can't be),
so pass it as an argument. `Sprite` is a plain MFL struct and caches fine — build them
once in `art_new()`, never per frame. (A slice of *plain* structs inside a struct is
fine — `Shade.mats []Mat` works; it is only the cstructs that cannot nest.)

## Making a sprite from a sentence

```sh
ressort proj init --name game --canvas 24x40 --assets assets
ressort sprite ask "a gunman in a long coat" --name gunman   # -> a task, not a picture
#   …write assets/gunman.sil AND assets/gunman.rig, in one go…
ressort sprite check gunman [--tone]                          # exit 0 / 90, writes the .spr when clean
```

- **`ressort.proj` is the style contract**: canvas, anchor, light, materials (which claim the
  zone chars `#%@&+` in order), and the house skeleton. Every `ask` inherits it, which is what
  stops a set of sprites from looking like a set of strangers. `--like <f.spr>` puts a
  reference tone map in the brief for proportions.
- **Ask for the drawing and the rig in ONE reply.** Splitting them turns rigging into
  archaeology on someone else's picture.
- `check` runs everything and answers once: canvas, connectivity (islands), anchor row, leg
  gap, rig coverage, pose survival. **Every problem carries a `fix` in the format's own
  vocabulary** — that is what makes an agent converge in one round instead of five.
- It reports **facts** separately from **problems** (extent, opaque count, largest mass, leg
  gap, checksum). Facts are for judgement, problems are for correction.
- **It declines to judge what it cannot measure.** Below `POSE_MIN` (200 opaque px) the pose
  check does not run and `pose_checked:0` says so: a two-pixel leg rotated 70° loses pixels to
  nearest-neighbour sampling, and reporting physics as a defect teaches the caller to ignore
  the checker. A checker that cries wolf is worse than none.
- **`ressort status`** — every asset, its state (`no rig` / `uncovered` / `flat rig` /
  `stale spr` / `stale anm`) and the one command that advances it. Staleness is `stat` mtime,
  so an `.anm` older than its `.rig` is flagged before the animation is trusted. This is the
  project view; there is no GUI and there will not be one.
- **`ressort sprite compare a b …`** — tone maps in columns plus the facts; `--contact F.png`
  for the human. How an agent picks among candidates without opening files.
- **`ressort sprite critique <name> --blind`** — the sprite with the request, name and palette
  withheld, and one question. `ask` writes the request to `<name>.ask` so it can be kept back
  until the cold reader has answered.
- Nothing here judges whether it *looks like* the request. That needs a cold reader — render
  the tone map and ask a fresh agent to name what it sees, blind.

## The forge — do not hand-shade a sprite

`engine/70_forge.src` derives the expensive two thirds of a sprite. Draw a flat mass
and let it do the rest; only reach for hand-authored rows when the result is not good
enough, and then start from `--src`.

```sh
ressort sprite list                       # every sprite + palette indices + checksum
ressort sprite show <name> [--src]
ressort sprite mats                       # the material presets and their ramps
ressort sprite shade game/sil/drone.sil   # flat drawing → shaded sprite
ressort sprite reshade <name> --mat navy  # flatten a hand-drawn sprite, shade it again
ressort sprite anim <name> --kind walk|breathe|recoil|flinch|tumble --frames N
ressort sprite variant <name> --from bc --to hi
ressort sprite sheet out.png [--sil F]    # the only verb here that needs a display
```

A `.sil` file is a drawing plus directives — `name`, `anchor ax ay`, `zone <char> <mat>`,
`light -1|1`, `mirror <overlap>`. Everything else in the file is a row of art.

- The shader does **form**, not material assignment. One zone character = one material,
  so a character with skin, hair and a vest needs three zone characters (`#`, `%`, `@`).
- A character no material claims **passes through unchanged** — hand-place an eye or a
  muzzle in the silhouette and the shader draws around it.
- Ramps are brightest-first. Depth below the top surface picks the entry; the rim
  overrides on the lit edge and the top, occlusion overrides underneath and on the
  shadow edge. Short ramps + `outline=1` on a narrow sprite = mostly outline.
- `sil_mirror` squares ragged rows first. It has to: otherwise every row mirrors around
  its own right edge instead of the drawing's centre, and the sprite comes out lopsided.
- Animation transforms clip at the canvas edge — the margins in these sprites are the
  budget. `spr_offset` moves the *anchor*, so a recoil costs nothing at all.
- `spr_sum` digests rows + anchor exactly as the sim digests the world. Pin new sprites
  in `t_forge` and a shader change stops being invisible.

## Art from outside — `sprite import`

For anything above ~32x32 the forge's shader is the wrong tool; transcribe instead.

```sh
ressort sprite import x.png --name n --native 100 --out n.src   # headless
ressort sprite import x.png --native 100 --compare c.png        # original vs transcription
ressort sprite import x.png --native 100 --compare /dev/null --bench 24   # draw-call cost
```

- raylib is the PNG decoder only (`LoadImage`/`GetImageColor` are CPU-side — **no window
  needed**; `--compare`/`--bench` do open one). `ImageFormat(img, RL_RGBA8())` first, or
  `GetImageColor` reads whatever layout the file happened to carry.
- **32 colours is the hard ceiling** — the format addresses `0`-`9` then `a`-`v`. Measured
  on a detailed 100x144 soldier, 32 is visually indistinguishable from 64 (RMS 7.3 vs 5.4)
  and even 16 survives. The palette limit is not what will stop you.
- An imported character carries **its own palette** (`pal_<name>()`); `spr_draw` already
  takes the table as an argument, so nothing about the format changes.
- **`--key auto|rrggbb` keys out a background colour.** Reference art is usually an
  illustration on white with no alpha at all; without keying the whole canvas is opaque and
  the bbox is the whole file. `auto` takes the top-left pixel. `--key-tol` defaults to 26.
- Integer upscales are found by boundary contrast (`step_score`). A non-integer scale has
  no grid — the importer reports `detected_scale: 0` rather than guessing, and you pass
  `--native WxH` to area-resample.
## Two-bone limbs

`leg_front` + `leg_front_lower` (`parent=leg_front`, pivot on the knee) — same for
`leg_back`, `arm_front`, `arm_back`. Roles 7–10; `rig_role` checks the `*_lower` prefixes
FIRST, since "leg_front_lower" also starts with "leg_front" and a shin must not be told it is
a thigh. Joints bend ONE WAY (the negative half of the sine is discarded, not mirrored) — a
hyperextending knee looks worse than none. `sprite check` reports a lower limb that is not
parented to its upper, because a shin pivoting at the hip swings the whole leg twice. This is
the single biggest visual win available: a one-box limb can only swing like a pendulum, which
is what makes a walk read as a march.

## Asking a human — `bin/ressort-taste`

```sh
ressort-taste hero.spr hero.rig --pairs 20 [--kind walk] [--base F.mot] [--amount 0.45]
ressort-taste hero.spr hero.rig --pairs 5 --auto     # answered by fitness, for CI
```

- A window is right HERE and nowhere else in this toolchain: agent-facing verbs are headless
  because an agent reads text; this one opens a window because a person watches motion.
- **Never ask about a pair that does not LOOK different.** `mot_distance` is the share of
  pixels that differ frame-for-frame; pairs below `--min-diff` (0.12) are not shown. The first
  session lacked this and came back 13-7 for the left-hand side — a position bias is what a
  person produces when the content cannot decide it.
- Candidates are **styles**, not noise: six coarse axes (`amp knee lag arms bob lean`) at three
  levels each, via `mot_restyle`. Two candidates differing by one level on one axis differ in a
  way a person can describe out loud, which is what makes the data worth fitting.
- **Cap candidate reuse.** Pairs are chosen so no candidate appears more than twice, and a
  ONE-axis difference is preferred — a single-axis verdict is the only attributable kind. A
  session before that cap came back with the same loser in 10 of 12 pairs: twelve clicks
  answering one question twelve times. `ressort-taste report <f.pref>` prints the per-axis
  tally AND warns when the spread failed, because a readout that cannot say it is wrong is
  worse than none.
- `ressort-evolve --runs N --out-dir D` gives champions from independent local optima;
  `ressort-taste --bases a.mot,b.mot,…` makes them compete. Cross-lineage pairs are a different
  question from cousins-of-one-base.
- **`role_limit` is the anatomy**, and both the styler and the search obey it. With a flat cap
  the evolver leaned torsos as far as it swung knees and every champion walked tilted; with
  per-joint limits the champions came out upright AND scored higher.
- `taste.pref` records the **seed**, not the motion (deterministic from base+seed), plus an
  11-feature vector per candidate. Features are dimensions a person can NAME, so a fitted
  model can be read back to them in their own words.
- Tags (`1`–`5`) describe what was wrong with the LOSER. Free text belongs in `taste.md` at
  the end of a session, and comes to the agent — a sentence usually names a measurement the
  fitness function does not have yet, which is where `poise` came from.

## The two holes a person found by watching

- **`tread`** — while a foot is DOWN it must travel backward relative to the direction of
  travel. Every other term (`keep ground stride flow poise`) scores a reversed cycle
  identically, which is how a search produced a man walking backwards. Asserted against a
  reversed walk.
- **`lean` is SIGNED, and `upright` is its own term.** An absolute value cannot tell forward
  from backward, so "wants less lean" meant "less of either" and the search leaned the body
  to the joint limit. A figure drawn standing up should stay standing up.
- Related: `ressort-taste winner <pref> <spr> <rig> --out best.mot` hands back the candidate
  a person actually picked, regenerated from the style the file recorded. It scores LOWER on
  stride and tread than the search champion and looks better — so the weighting is still
  wrong and the eye is still ahead of it. Say that out loud rather than shipping the number.

## Fitting a taste

```sh
ressort-evolve fit taste3.pref --out taste.json          # pairwise prefs -> a scorer
ressort-evolve walk h.spr h.rig --taste taste.json [--taste-weight 2.0]
```

- Bradley-Terry: train on `features(winner) - features(loser)` with the mirrored row as its
  own negative, one linear layer, so **the weights ARE the taste** and read back in the words
  the features were named in.
- **Fit only the axes a person varies** (`taste_index`: leg_amp, knee_amp, arm_amp, bob, lean,
  antiphase). Fitting all eleven to fifteen judgements produced "wants MORE leg_amp" while
  every winner had LESS — an unidentified system stating a confident lie.
- **Print the mean winner-minus-loser beside every weight** and mark a disagreeing sign
  `UNSTABLE`; warn below four judgements per weight.
- **The learned reward is only valid near the data.** Nobody ever rated a motion where the
  body came apart, so the engineered terms stay on at half weight as a guardrail: the human
  decides what is good, the measurements decide what is admissible.
- Score the INCUMBENT with the same function as the candidates. The first version compared a
  taste-weighted champion against an unweighted incumbent and declared the incumbent the
  winner by two points of a unit that did not exist.

## Motion as data — `.mot` and the search

`engine/97_motion.src` makes a motion a TABLE (per frame, per role: angle + offset, plus the
one global transform), and `bin/ressort-evolve` searches for one.

```sh
ressort-evolve walk hero.spr hero.rig --out hero-walk.mot [--gens 30 --pop 40 --seed 42]
ressort sprite anim hero.spr --rig hero.rig --mot hero-walk.mot --sheet s.png
```

- **The net is the search, the table is the artifact.** tinybrain evolves a controller
  `(sin φ, cos φ, role) → (angle, dx, dy)`; the champion is SAMPLED onto the frame grid and
  written as a `.mot`. Nothing infers at runtime, so the engine keeps zero ML dependency and
  the asset stays text. `bin/ressort-evolve` builds only when `$TINYBRAIN` exists.
- **Phase enters as sin/cos**, so the cycle closes by construction — no fitness term has to
  ask for periodicity and no champion can cheat by ignoring it.
- **Angles are integer ten-thousandths of a radian.** Floats printed into a text file
  round-trip at the printer's whim; integers round-trip exactly and still resolve 0.01 px.
- **`mot_from_canned` + `rig_anim_mot` == `rig_anim`, frame for frame** (asserted for all five
  kinds). That bridge is what makes an evolved table trustworthy — without it a champion is
  scored against something the engine does not draw.
- **The fitness function is where the bugs live, not the search.** Two real ones: `stride`
  averaged the bottom eighth of the CANVAS, which is padding on a posed frame, so it read zero
  for every walk ever written; and with `keep`/`ground`/`stride`/`flow` alone the first
  champion scored 8.90 by leaning 33° forward forever. `poise` (mean overlap with the drawn
  pose, as a floor) is the term that says *an animation is an oscillation around the pose that
  was drawn*. Measure, then LOOK — the number said 8.90 and the sheet said no.
- A search that cannot lose is measuring the wrong thing: the tool scores the incumbent curve
  by the same measure and prints `verdict: incumbent` when the hand-written one still wins.

## Animation — the tool asks, the agent answers

`engine/90_rig.src`. Posing a limb needs to know which pixels ARE the limb; that is a
judgement, so the tool asks for it instead of guessing, grepapi-style — the CLI states
the task, the agent replies with a file, the CLI grades it.

```sh
ressort sprite brief hero.spr --for walk      # sprite as TONE + the .rig grammar
#   …the agent writes hero.rig…
ressort sprite rig hero.spr --rig hero.rig    # coverage check, exit 0 / 90
ressort sprite anim hero.spr --rig hero.rig --kind walk --frames 8 --sheet w.png
```

- `.spr` is a sprite file (own palette, own anchor, still text) so the tool no longer
  depends on the game's `Art`. `sprite import --spr F` writes one. Any verb takes a
  registry name **or** a `.spr` path.
- A rig is rectangles: `part <name> x=x0,x1 y=y0,y1 pivot=px,py z=order`. **Roles come
  from the name prefix** — `head torso arm_back arm_front leg_back leg_front`; anything
  else is carried but never moved.
- **A part may be several boxes.** Same role prefix + same pivot = one rigid part. This
  is the fix for a rifle held diagonally across a chest: one rectangle either misses the
  barrel or swings the vest. Learned the hard way — the first rig hollowed out his chest.
- **Run `ressort sprite rig` before wiring any rig into anything.** The demo's own hand-written
  fallback rig scored 253/288 — pixels that would have vanished silently on the first pose. The
  check costs one command and catches what eyes do not.
- Coverage is the review mechanism: an uncovered opaque pixel **disappears when posed**,
  so `sprite rig` exits 90 and names the first row. Do not skip it.
- **`parent=<part>` makes it a skeleton**, and `walk`/`idle` do not need it but `recoil`
  and `death` are meaningless without it — those are chains (hips lead, shoulders arrive
  late). A child inherits its parent's transform and states only what it adds, so anything
  BODY-WIDE (the walk bob, the recoil shove, the fall's drop) is applied to **roots only**;
  a flat rig has all roots and behaves exactly as before. The hierarchy that works:
  `torso` a root pivoting at the hips, `head`/`arm_*` with `parent=torso`, and the **legs
  as roots** — parented to the torso they lift off the ground whenever the body leans.
  `sprite rig` prints `roots` and `depth`, and warns when depth is 1.
- **`rig_pose_g` carries a virtual root** outside every chain: a body going over backwards
  rotates entirely, about a point on the ground, which no per-part pivot expresses. `death`
  drives it from the sprite's own anchor (`s.ax, s.h`) and lets the parts add the lag.
  That is also why `--kind death` defaults `--pad` to 46: a toppled figure is as wide as it
  was tall, and the default 10 clips it.
- Unknown parent, self-parent and cycles are decode errors — an agent writes these.

### Exporting

```sh
ressort sprite anim h.spr --rig h.rig --kind walk --frames 8 \
    --out-anm w.anm --out-png w.png --out-json w.json --out-src w.src --out-spr w.spr
ressort sprite verify w.anm      # re-pose and check every frame  (exit 0 / 90)
```

- **`anim_pack` first, always.** Posed frames differ in size and anchor; the pack builds one
  box from the union of all frames ALIGNED BY ANCHOR. Skip it and the sheet jitters against
  the ground in every consuming engine. Every export is built from the packed frames.
- `.anm` is the **recipe** (352 B vs 97 kB baked) and carries a checksum per frame, so
  `sprite verify` re-poses it and exits 0/90 — the animation equivalent of `demoman verify`.
  Its `sprite`/`rig` paths are stored as written and retried **relative to the manifest**.
- The PNG is written with `alloc`/`poke_u8` into an `Image{ptr,w,h,1,7}` handed to
  `ExportImage` — **CPU-side, no window**, so exporting works in CI. `free` the buffer after;
  raylib does not take ownership.
- `spf_decode` reads **one** block and stops at the first `end`. It has to: a multi-frame
  `.spr` would otherwise come back as one very tall sprite carrying the LAST block's name.
  Use `spf_frames` for the multi-frame case.
- `loop` and per-frame `duration` belong to the **kind**, not the rig (`kind_loops`,
  `kind_fps`): a walk loops, a death and a recoil hold their last frame.
- **A cycle divides by `n`; a transition divides by `n-1`.** `kind_loops` decides which:
  a walk's frame `n` would be frame 0 again, but an aim or a topple has to ARRIVE, so its
  last frame is `t = 1`. Getting this wrong is invisible in code and obvious on screen —
  the topple used to stop three quarters of the way over. `kind_frames` gives each motion
  a sensible default count (a breath wants 10, a recoil 6).
- Amplitudes are small on purpose. A rifle already held across the chest only has to come
  up to the eye: rotating an arm 30° about its shoulder tears it off the body. `aim` is
  0.24 rad, and `idle` is mostly a 0.02 rad lean plus one pixel — rotation is continuous
  where a pixel offset is not, so lean carries the motion and `dy` only punctuates it.
- Kinds are canned and deterministic (`walk idle aim recoil death`): the rig says where
  the parts are, the kind says how they move, and neither knows about the other. Same rig
  → same checksums, which is what makes an agent-written rig reviewable.
- The poser samples destination→source (inverse rotation), so rotations never leave holes.
  It pads the canvas (`--pad`, default 10) and moves the anchor with it.

- What actually bites at this scale: **14,400 characters per pose** (66x a SNES sprite), and
  `spr_draw`'s one `DrawRectangle` per opaque pixel. Measured ceiling ~60k rects/frame
  (~12 detailed characters). Past that, switch to a texture upload.

## MFL traps this engine hit

Beyond the [machin-gamedev](../machin-gamedev/SKILL.md) list:

1. **Signed overflow is UB the optimizer folds.** `*` and `<<` emit signed 64-bit C ops.
   The textbook 64-bit FNV round `h = (h ^ b) * 1099511628211` collapsed to a constant
   (`INT64_MAX`) at `-O2` — the checksum silently stopped discriminating. Same hazard in
   xorshift64\*. **Keep every intermediate inside int64**: two 32-bit lanes, mask after
   each step, pack as `(hi & 0x7FFFFFFF) * 4294967296 + (lo & 0xFFFFFFFF)`. See
   `fnv_i` / `rng_next` in `engine/00_core.src`.
2. **u8 cstruct fields wrap silently.** `col_mul(orange, 1.5)` came back green. Clamp
   before constructing a `Color`.
3. **Int literals are 64-bit signed** — `0x9E3779B97F4A7C15` is a *parse* error
   ("value out of range"). Top nibble ≤ 7.
4. **`charat` returns a string**, not a byte. Use `byte_at(bytes(s), i)`. The typecheck
   error blames your accumulator, not `charat`.
5. **Multi-assign into a struct field does not parse.** `ns.rng, v = f()` errors at the
   comma. Assign to temporaries first.
6. **One inferred type per parameter, program-wide.** A test helper `ok(flag)` cannot take
   both an int and a bool; write two functions.
7. `Ent{}` (empty literal) zero-fills and works. Non-empty `[]Struct{a,b}` still does not.
8. `_` is not assignable: `_ = f()` is "assignment to undefined variable".
9. **`:=` is FUNCTION-scoped, not block-scoped.** The same name holding two types in
   *disjoint branches of one function* is a hard error, not two block-locals: a `Shade`
   named `sh` in one CLI subcommand and a `string` named `sh` in another failed to
   typecheck, as did `n` used for a slice in one branch and an int in the next. Rename;
   there is no shadowing to fall back on.
10. **NEVER inline a call in an FFI argument list next to an INOUT struct param.**
   `ImageFormat(img, RL_RGBA8())` miscompiles: in a small program the conversion silently
   does not happen (the format stays whatever the file was), and in a large one it
   **segfaults inside the call**. Hoist it: `rgba := RL_RGBA8()` then `ImageFormat(img, rgba)`.
   This cost half a session — the symptom was an importer that worked on one PNG and died
   on another, and the difference was only that the second file needed converting.
11. **A named return's type is inferred from the CALL SITE.** `func f() (v) { v = 1 }`
   used as `if f() { … }` types `v` as *bool*, and every `v = 0` inside the function then
   fails to typecheck — the error points at the function, not at the caller. Write
   `if f() == 1`.

## Debugging a divergent replay

`verify` prints `first_divergent_frame`. Re-run `sim --frames <that> --trace` on both
and diff. The usual causes, in order:

1. Something in the synced half read the view, the clock, or `rand_bytes`.
2. A system iterated `len(scene.ents)` *while spawning* — capture `n := sim_n(s)` before
   the loop, or new rows get stepped in the same frame they were created.
3. Game state kept outside `Sim` (a local in the loop) — it is not checksummed and does
   not replay. The attract bot's waypoint index lives outside `Sim` **on purpose**,
   because on replay the bot does not run at all.

## Known limits

The attract bot is a route-follower, not a good player: it clears three floors, rides the
cable and rescues all four hostages, but does not reliably finish the stage. Determinism
is same-binary/same-platform — Spring needs STREFLOP for cross-CPU bit-equality and
ressort has no equivalent. No audio yet.

