machin-ressort
A 2D engine in MFL at ~/ai/machin-ressort, plus a Demolition Man stage-1 POC.
Successor to 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
./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
- A
kind code in game/10_defs.src.
- A
prefab line in the .rml, or an entity line for a one-off.
- A branch in
game/30_sim.src (a system, or a case in an existing one).
- 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
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.
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.
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
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
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.
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.
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
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 list:
- 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.
- u8 cstruct fields wrap silently.
col_mul(orange, 1.5) came back green. Clamp
before constructing a Color.
- Int literals are 64-bit signed —
0x9E3779B97F4A7C15 is a parse error
("value out of range"). Top nibble ≤ 7.
charat returns a string, not a byte. Use byte_at(bytes(s), i). The typecheck
error blames your accumulator, not charat.
- Multi-assign into a struct field does not parse.
ns.rng, v = f() errors at the
comma. Assign to temporaries first.
- One inferred type per parameter, program-wide. A test helper
ok(flag) cannot take
both an int and a bool; write two functions.
Ent{} (empty literal) zero-fills and works. Non-empty []Struct{a,b} still does not.
_ is not assignable: _ = f() is "assignment to undefined variable".
:= 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.
- 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.
- 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:
- Something in the synced half read the view, the clock, or
rand_bytes.
- 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.
- 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.
1---2name: machin-ressort3description: 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.4---56# machin-ressort78A 2D engine in MFL at `~/ai/machin-ressort`, plus a Demolition Man stage-1 POC.9Successor to [machin-gum-2d](https://github.com/javimosch/machin-gum-2d) (525 lines,10Vec2/verlet/particles); ressort is the ambitious one.1112Read `README.md` first for the why. This is the how.1314## The one rule1516**Synced code may never touch the view. Unsynced code may never write the world.**1718| synced (deterministic, headless, checksummed) | unsynced (raylib) |19|---|---|20| `engine/00_core 10_scene 20_tilemap 30_sim 40_scenefile` | `engine/50_ffi 60_view 70_forge 80_import 90_rig` |21| `game/10_defs 30_sim` | `game/20_art 40_view` |2223Synced code must not call: raylib, `now()`, `now_ms()`, `rand_bytes()`, `GetFrameTime()`.24For randomness use `sim_rand(s,n)` / `sim_randf(s)` / `sim_randr(s,lo,hi)` /25`sim_chance(s,pct)` — they thread the PRNG that lives *inside* the world state.2627The view reacts to the sim only through **events**: the sim calls28`sim_emit(s, EV_*, x, y, a, i)`, the view drains `s.events` each rendered frame. If you29find yourself wanting to mutate `s` from the view, the answer is a new event.3031Break either rule and `demoman verify` stops meaning anything.3233## Three binaries3435**`bin/ressort` is the engine's sprite toolchain** (`tools/sprite.src`); **`bin/soldier` is36the playable pipeline demo** (`demo/soldier.src` — WASD + fire, runs on a forged fallback37figure when given no `--spr`, so no third-party art ships here); **`bin/demoman` is the38Demolition Man stage** (`game/*.src`). Both are ENGINE + one main. The tool works on39`.spr`/`.sil`/`.rig`/`.anm` files and knows nothing about any game's art table; the game40keeps `demoman art list|show|sheet` for its own sprites. Anything both need (flag parsing,41the JSON printers, `SCREEN_W/H`, `pal_default`) lives in the engine — `game/20_art.src`'s42`dm_palette()` is now a one-line alias over `pal_default()`.4344## Build & verify4546```sh47./build.sh # → bin/ressort + bin/demoman (vendors raylib 5.0 static into vendor/)48./build.sh test # headless engine suite, exit 0/14950./bin/demoman scene game/level1.rml # validate a level, JSON out51./bin/demoman sim --frames 9000 --trace --record run.rr52./bin/demoman verify run.rr # exit 0 reproduced / 90 diverged / 2 bad input53./bin/demoman shot out.png --at 600 # DISPLAY=:0; raylib writes to CWD, not the path's dir54```5556`machin encode a.src b.src c.src` concatenates modules — that is the module system.57Order matters only for readability; the whole program is typechecked together, so the58engine can call the game's `game_step` hook by name.5960Screenshot gotcha: `TakeScreenshot("docs/x.png")` writes `./x.png` — raylib strips the61directory. Move it afterwards.6263Font gotcha: raylib's **default font is ASCII-only**. An em dash, `→` or `·` passed to64`hud_text`/`DrawText` renders as `?`, and you only find out by reading the screenshot65back. Keep anything drawn on screen ASCII; the docs can keep their typography.6667## Adding an entity type68691. A `kind` code in `game/10_defs.src`.702. A `prefab` line in the `.rml`, or an `entity` line for a one-off.713. A branch in `game/30_sim.src` (a system, or a case in an existing one).724. A sprite in `game/20_art.src` (forge it first — see below) and a branch in73 `draw_entities`. Add the name to `art_names` / `art_by_name` in `game/40_view.src`74 or it will not show up in `ressort sprite`.7576Slots the **engine** owns on every `Ent` — do not repurpose:7778| slot | owner | meaning |79|---|---|---|80| `s2` | `tm_move` | 1 = fall through one-way ledges this step |81| `s3` | `sys_physics` | 1 = suspend gravity (rope, ladder, zipline, death) |82| `t0` | `sys_lifetime` | countdown, `BEH_LIFETIME` only — never give the player that bit |8384Everything else (`s0 s1 f0..f3 t1`) is the game's. Document which behaviour owns which.8586## The scene format (`.rml`)8788```89scene name=… gravity=… tile=32 seed=… # once90set key=value key=value # metadata; NO SPACES in a value91layer name=… depth=0..1 color=r,g,b y= h= kind=0..492prefab <name> k=v … # a reusable row93spawn <prefab> at=cx,cy [overrides] # overrides win over the prefab94entity kind=… name=… at=cx,cy hw= hh= hp= beh=a,b,c95tiles96…rows…97end98```99100- `at=` is in **cells**; the entity's feet land at that cell's bottom. Standing on a101 floor at row R means `cy = R-1`. `px=x,y` overrides with raw pixels.102- `beh=` names resolve via `beh_by_name`; an unknown name is a **parse error**, not a103 silent drop. So is an unknown prefab and a missing `tiles` block.104- layer `kind`: 0 flat band, 1 skyline, 2 lit windows, 3 smoke, 4 stars.105- tile legend: `.` air, `#` solid, `=` one-way ledge, `H` ladder, `T` ladder top106 (standable + climb-through), `^` fire, `-` zip cable, `,` decor.107108**Ladder topology.** A ladder joining an upper floor at row `RU` to a lower floor at row109`RL` is `T` at `(x, RU)` and `H` at `(x, RU+1 … RL-1)`. The actor standing on the lower110floor is then already *inside* the bottom rung, so `UP` grabs it, and `T` replaces the111upper floor tile so the climb passes through. Get this backwards and the ladder dead-ends112into a solid slab.113114**Vertical clearance.** An actor `2*hh` px tall cannot fit a gap of `tile` px if115`2*hh >= tile`. With 32 px tiles, `hh=15` (30 px) passes a one-tile gap; `hh=17` (34 px)116does not, and every one-tile corridor becomes an invisible wall. This cost hours.117118## Text sprites119120```121func art_x() (s) {122 r := []string{}123 r = append(r, "..3333..") // '.'/' ' transparent, '0'-'9' then 'a'-'v' = palette 0..31124 s = spr_anchor(spr(r), 4, 8) // anchor (ax, ay) in sprite pixels; feet = (w/2, h)125}126spr_draw(sprite, palette, x, y, pixel_scale, flip, tint) // flip = -1 mirrors127spr_draw_flat(sprite, color, x, y, scale, flip) // silhouette (shadows)128```129130The palette is a `[]Color`. It **cannot** be a field of an MFL struct (cstructs can't be),131so pass it as an argument. `Sprite` is a plain MFL struct and caches fine — build them132once in `art_new()`, never per frame. (A slice of *plain* structs inside a struct is133fine — `Shade.mats []Mat` works; it is only the cstructs that cannot nest.)134135## Making a sprite from a sentence136137```sh138ressort proj init --name game --canvas 24x40 --assets assets139ressort sprite ask "a gunman in a long coat" --name gunman # -> a task, not a picture140# …write assets/gunman.sil AND assets/gunman.rig, in one go…141ressort sprite check gunman [--tone] # exit 0 / 90, writes the .spr when clean142```143144- **`ressort.proj` is the style contract**: canvas, anchor, light, materials (which claim the145 zone chars `#%@&+` in order), and the house skeleton. Every `ask` inherits it, which is what146 stops a set of sprites from looking like a set of strangers. `--like <f.spr>` puts a147 reference tone map in the brief for proportions.148- **Ask for the drawing and the rig in ONE reply.** Splitting them turns rigging into149 archaeology on someone else's picture.150- `check` runs everything and answers once: canvas, connectivity (islands), anchor row, leg151 gap, rig coverage, pose survival. **Every problem carries a `fix` in the format's own152 vocabulary** — that is what makes an agent converge in one round instead of five.153- It reports **facts** separately from **problems** (extent, opaque count, largest mass, leg154 gap, checksum). Facts are for judgement, problems are for correction.155- **It declines to judge what it cannot measure.** Below `POSE_MIN` (200 opaque px) the pose156 check does not run and `pose_checked:0` says so: a two-pixel leg rotated 70° loses pixels to157 nearest-neighbour sampling, and reporting physics as a defect teaches the caller to ignore158 the checker. A checker that cries wolf is worse than none.159- **`ressort status`** — every asset, its state (`no rig` / `uncovered` / `flat rig` /160 `stale spr` / `stale anm`) and the one command that advances it. Staleness is `stat` mtime,161 so an `.anm` older than its `.rig` is flagged before the animation is trusted. This is the162 project view; there is no GUI and there will not be one.163- **`ressort sprite compare a b …`** — tone maps in columns plus the facts; `--contact F.png`164 for the human. How an agent picks among candidates without opening files.165- **`ressort sprite critique <name> --blind`** — the sprite with the request, name and palette166 withheld, and one question. `ask` writes the request to `<name>.ask` so it can be kept back167 until the cold reader has answered.168- Nothing here judges whether it *looks like* the request. That needs a cold reader — render169 the tone map and ask a fresh agent to name what it sees, blind.170171## The forge — do not hand-shade a sprite172173`engine/70_forge.src` derives the expensive two thirds of a sprite. Draw a flat mass174and let it do the rest; only reach for hand-authored rows when the result is not good175enough, and then start from `--src`.176177```sh178ressort sprite list # every sprite + palette indices + checksum179ressort sprite show <name> [--src]180ressort sprite mats # the material presets and their ramps181ressort sprite shade game/sil/drone.sil # flat drawing → shaded sprite182ressort sprite reshade <name> --mat navy # flatten a hand-drawn sprite, shade it again183ressort sprite anim <name> --kind walk|breathe|recoil|flinch|tumble --frames N184ressort sprite variant <name> --from bc --to hi185ressort sprite sheet out.png [--sil F] # the only verb here that needs a display186```187188A `.sil` file is a drawing plus directives — `name`, `anchor ax ay`, `zone <char> <mat>`,189`light -1|1`, `mirror <overlap>`. Everything else in the file is a row of art.190191- The shader does **form**, not material assignment. One zone character = one material,192 so a character with skin, hair and a vest needs three zone characters (`#`, `%`, `@`).193- A character no material claims **passes through unchanged** — hand-place an eye or a194 muzzle in the silhouette and the shader draws around it.195- Ramps are brightest-first. Depth below the top surface picks the entry; the rim196 overrides on the lit edge and the top, occlusion overrides underneath and on the197 shadow edge. Short ramps + `outline=1` on a narrow sprite = mostly outline.198- `sil_mirror` squares ragged rows first. It has to: otherwise every row mirrors around199 its own right edge instead of the drawing's centre, and the sprite comes out lopsided.200- Animation transforms clip at the canvas edge — the margins in these sprites are the201 budget. `spr_offset` moves the *anchor*, so a recoil costs nothing at all.202- `spr_sum` digests rows + anchor exactly as the sim digests the world. Pin new sprites203 in `t_forge` and a shader change stops being invisible.204205## Art from outside — `sprite import`206207For anything above ~32x32 the forge's shader is the wrong tool; transcribe instead.208209```sh210ressort sprite import x.png --name n --native 100 --out n.src # headless211ressort sprite import x.png --native 100 --compare c.png # original vs transcription212ressort sprite import x.png --native 100 --compare /dev/null --bench 24 # draw-call cost213```214215- raylib is the PNG decoder only (`LoadImage`/`GetImageColor` are CPU-side — **no window216 needed**; `--compare`/`--bench` do open one). `ImageFormat(img, RL_RGBA8())` first, or217 `GetImageColor` reads whatever layout the file happened to carry.218- **32 colours is the hard ceiling** — the format addresses `0`-`9` then `a`-`v`. Measured219 on a detailed 100x144 soldier, 32 is visually indistinguishable from 64 (RMS 7.3 vs 5.4)220 and even 16 survives. The palette limit is not what will stop you.221- An imported character carries **its own palette** (`pal_<name>()`); `spr_draw` already222 takes the table as an argument, so nothing about the format changes.223- **`--key auto|rrggbb` keys out a background colour.** Reference art is usually an224 illustration on white with no alpha at all; without keying the whole canvas is opaque and225 the bbox is the whole file. `auto` takes the top-left pixel. `--key-tol` defaults to 26.226- Integer upscales are found by boundary contrast (`step_score`). A non-integer scale has227 no grid — the importer reports `detected_scale: 0` rather than guessing, and you pass228 `--native WxH` to area-resample.229## Two-bone limbs230231`leg_front` + `leg_front_lower` (`parent=leg_front`, pivot on the knee) — same for232`leg_back`, `arm_front`, `arm_back`. Roles 7–10; `rig_role` checks the `*_lower` prefixes233FIRST, since "leg_front_lower" also starts with "leg_front" and a shin must not be told it is234a thigh. Joints bend ONE WAY (the negative half of the sine is discarded, not mirrored) — a235hyperextending knee looks worse than none. `sprite check` reports a lower limb that is not236parented to its upper, because a shin pivoting at the hip swings the whole leg twice. This is237the single biggest visual win available: a one-box limb can only swing like a pendulum, which238is what makes a walk read as a march.239240## Asking a human — `bin/ressort-taste`241242```sh243ressort-taste hero.spr hero.rig --pairs 20 [--kind walk] [--base F.mot] [--amount 0.45]244ressort-taste hero.spr hero.rig --pairs 5 --auto # answered by fitness, for CI245```246247- A window is right HERE and nowhere else in this toolchain: agent-facing verbs are headless248 because an agent reads text; this one opens a window because a person watches motion.249- **Never ask about a pair that does not LOOK different.** `mot_distance` is the share of250 pixels that differ frame-for-frame; pairs below `--min-diff` (0.12) are not shown. The first251 session lacked this and came back 13-7 for the left-hand side — a position bias is what a252 person produces when the content cannot decide it.253- Candidates are **styles**, not noise: six coarse axes (`amp knee lag arms bob lean`) at three254 levels each, via `mot_restyle`. Two candidates differing by one level on one axis differ in a255 way a person can describe out loud, which is what makes the data worth fitting.256- **Cap candidate reuse.** Pairs are chosen so no candidate appears more than twice, and a257 ONE-axis difference is preferred — a single-axis verdict is the only attributable kind. A258 session before that cap came back with the same loser in 10 of 12 pairs: twelve clicks259 answering one question twelve times. `ressort-taste report <f.pref>` prints the per-axis260 tally AND warns when the spread failed, because a readout that cannot say it is wrong is261 worse than none.262- `ressort-evolve --runs N --out-dir D` gives champions from independent local optima;263 `ressort-taste --bases a.mot,b.mot,…` makes them compete. Cross-lineage pairs are a different264 question from cousins-of-one-base.265- **`role_limit` is the anatomy**, and both the styler and the search obey it. With a flat cap266 the evolver leaned torsos as far as it swung knees and every champion walked tilted; with267 per-joint limits the champions came out upright AND scored higher.268- `taste.pref` records the **seed**, not the motion (deterministic from base+seed), plus an269 11-feature vector per candidate. Features are dimensions a person can NAME, so a fitted270 model can be read back to them in their own words.271- Tags (`1`–`5`) describe what was wrong with the LOSER. Free text belongs in `taste.md` at272 the end of a session, and comes to the agent — a sentence usually names a measurement the273 fitness function does not have yet, which is where `poise` came from.274275## The two holes a person found by watching276277- **`tread`** — while a foot is DOWN it must travel backward relative to the direction of278 travel. Every other term (`keep ground stride flow poise`) scores a reversed cycle279 identically, which is how a search produced a man walking backwards. Asserted against a280 reversed walk.281- **`lean` is SIGNED, and `upright` is its own term.** An absolute value cannot tell forward282 from backward, so "wants less lean" meant "less of either" and the search leaned the body283 to the joint limit. A figure drawn standing up should stay standing up.284- Related: `ressort-taste winner <pref> <spr> <rig> --out best.mot` hands back the candidate285 a person actually picked, regenerated from the style the file recorded. It scores LOWER on286 stride and tread than the search champion and looks better — so the weighting is still287 wrong and the eye is still ahead of it. Say that out loud rather than shipping the number.288289## Fitting a taste290291```sh292ressort-evolve fit taste3.pref --out taste.json # pairwise prefs -> a scorer293ressort-evolve walk h.spr h.rig --taste taste.json [--taste-weight 2.0]294```295296- Bradley-Terry: train on `features(winner) - features(loser)` with the mirrored row as its297 own negative, one linear layer, so **the weights ARE the taste** and read back in the words298 the features were named in.299- **Fit only the axes a person varies** (`taste_index`: leg_amp, knee_amp, arm_amp, bob, lean,300 antiphase). Fitting all eleven to fifteen judgements produced "wants MORE leg_amp" while301 every winner had LESS — an unidentified system stating a confident lie.302- **Print the mean winner-minus-loser beside every weight** and mark a disagreeing sign303 `UNSTABLE`; warn below four judgements per weight.304- **The learned reward is only valid near the data.** Nobody ever rated a motion where the305 body came apart, so the engineered terms stay on at half weight as a guardrail: the human306 decides what is good, the measurements decide what is admissible.307- Score the INCUMBENT with the same function as the candidates. The first version compared a308 taste-weighted champion against an unweighted incumbent and declared the incumbent the309 winner by two points of a unit that did not exist.310311## Motion as data — `.mot` and the search312313`engine/97_motion.src` makes a motion a TABLE (per frame, per role: angle + offset, plus the314one global transform), and `bin/ressort-evolve` searches for one.315316```sh317ressort-evolve walk hero.spr hero.rig --out hero-walk.mot [--gens 30 --pop 40 --seed 42]318ressort sprite anim hero.spr --rig hero.rig --mot hero-walk.mot --sheet s.png319```320321- **The net is the search, the table is the artifact.** tinybrain evolves a controller322 `(sin φ, cos φ, role) → (angle, dx, dy)`; the champion is SAMPLED onto the frame grid and323 written as a `.mot`. Nothing infers at runtime, so the engine keeps zero ML dependency and324 the asset stays text. `bin/ressort-evolve` builds only when `$TINYBRAIN` exists.325- **Phase enters as sin/cos**, so the cycle closes by construction — no fitness term has to326 ask for periodicity and no champion can cheat by ignoring it.327- **Angles are integer ten-thousandths of a radian.** Floats printed into a text file328 round-trip at the printer's whim; integers round-trip exactly and still resolve 0.01 px.329- **`mot_from_canned` + `rig_anim_mot` == `rig_anim`, frame for frame** (asserted for all five330 kinds). That bridge is what makes an evolved table trustworthy — without it a champion is331 scored against something the engine does not draw.332- **The fitness function is where the bugs live, not the search.** Two real ones: `stride`333 averaged the bottom eighth of the CANVAS, which is padding on a posed frame, so it read zero334 for every walk ever written; and with `keep`/`ground`/`stride`/`flow` alone the first335 champion scored 8.90 by leaning 33° forward forever. `poise` (mean overlap with the drawn336 pose, as a floor) is the term that says *an animation is an oscillation around the pose that337 was drawn*. Measure, then LOOK — the number said 8.90 and the sheet said no.338- A search that cannot lose is measuring the wrong thing: the tool scores the incumbent curve339 by the same measure and prints `verdict: incumbent` when the hand-written one still wins.340341## Animation — the tool asks, the agent answers342343`engine/90_rig.src`. Posing a limb needs to know which pixels ARE the limb; that is a344judgement, so the tool asks for it instead of guessing, grepapi-style — the CLI states345the task, the agent replies with a file, the CLI grades it.346347```sh348ressort sprite brief hero.spr --for walk # sprite as TONE + the .rig grammar349# …the agent writes hero.rig…350ressort sprite rig hero.spr --rig hero.rig # coverage check, exit 0 / 90351ressort sprite anim hero.spr --rig hero.rig --kind walk --frames 8 --sheet w.png352```353354- `.spr` is a sprite file (own palette, own anchor, still text) so the tool no longer355 depends on the game's `Art`. `sprite import --spr F` writes one. Any verb takes a356 registry name **or** a `.spr` path.357- A rig is rectangles: `part <name> x=x0,x1 y=y0,y1 pivot=px,py z=order`. **Roles come358 from the name prefix** — `head torso arm_back arm_front leg_back leg_front`; anything359 else is carried but never moved.360- **A part may be several boxes.** Same role prefix + same pivot = one rigid part. This361 is the fix for a rifle held diagonally across a chest: one rectangle either misses the362 barrel or swings the vest. Learned the hard way — the first rig hollowed out his chest.363- **Run `ressort sprite rig` before wiring any rig into anything.** The demo's own hand-written364 fallback rig scored 253/288 — pixels that would have vanished silently on the first pose. The365 check costs one command and catches what eyes do not.366- Coverage is the review mechanism: an uncovered opaque pixel **disappears when posed**,367 so `sprite rig` exits 90 and names the first row. Do not skip it.368- **`parent=<part>` makes it a skeleton**, and `walk`/`idle` do not need it but `recoil`369 and `death` are meaningless without it — those are chains (hips lead, shoulders arrive370 late). A child inherits its parent's transform and states only what it adds, so anything371 BODY-WIDE (the walk bob, the recoil shove, the fall's drop) is applied to **roots only**;372 a flat rig has all roots and behaves exactly as before. The hierarchy that works:373 `torso` a root pivoting at the hips, `head`/`arm_*` with `parent=torso`, and the **legs374 as roots** — parented to the torso they lift off the ground whenever the body leans.375 `sprite rig` prints `roots` and `depth`, and warns when depth is 1.376- **`rig_pose_g` carries a virtual root** outside every chain: a body going over backwards377 rotates entirely, about a point on the ground, which no per-part pivot expresses. `death`378 drives it from the sprite's own anchor (`s.ax, s.h`) and lets the parts add the lag.379 That is also why `--kind death` defaults `--pad` to 46: a toppled figure is as wide as it380 was tall, and the default 10 clips it.381- Unknown parent, self-parent and cycles are decode errors — an agent writes these.382383### Exporting384385```sh386ressort sprite anim h.spr --rig h.rig --kind walk --frames 8 \387 --out-anm w.anm --out-png w.png --out-json w.json --out-src w.src --out-spr w.spr388ressort sprite verify w.anm # re-pose and check every frame (exit 0 / 90)389```390391- **`anim_pack` first, always.** Posed frames differ in size and anchor; the pack builds one392 box from the union of all frames ALIGNED BY ANCHOR. Skip it and the sheet jitters against393 the ground in every consuming engine. Every export is built from the packed frames.394- `.anm` is the **recipe** (352 B vs 97 kB baked) and carries a checksum per frame, so395 `sprite verify` re-poses it and exits 0/90 — the animation equivalent of `demoman verify`.396 Its `sprite`/`rig` paths are stored as written and retried **relative to the manifest**.397- The PNG is written with `alloc`/`poke_u8` into an `Image{ptr,w,h,1,7}` handed to398 `ExportImage` — **CPU-side, no window**, so exporting works in CI. `free` the buffer after;399 raylib does not take ownership.400- `spf_decode` reads **one** block and stops at the first `end`. It has to: a multi-frame401 `.spr` would otherwise come back as one very tall sprite carrying the LAST block's name.402 Use `spf_frames` for the multi-frame case.403- `loop` and per-frame `duration` belong to the **kind**, not the rig (`kind_loops`,404 `kind_fps`): a walk loops, a death and a recoil hold their last frame.405- **A cycle divides by `n`; a transition divides by `n-1`.** `kind_loops` decides which:406 a walk's frame `n` would be frame 0 again, but an aim or a topple has to ARRIVE, so its407 last frame is `t = 1`. Getting this wrong is invisible in code and obvious on screen —408 the topple used to stop three quarters of the way over. `kind_frames` gives each motion409 a sensible default count (a breath wants 10, a recoil 6).410- Amplitudes are small on purpose. A rifle already held across the chest only has to come411 up to the eye: rotating an arm 30° about its shoulder tears it off the body. `aim` is412 0.24 rad, and `idle` is mostly a 0.02 rad lean plus one pixel — rotation is continuous413 where a pixel offset is not, so lean carries the motion and `dy` only punctuates it.414- Kinds are canned and deterministic (`walk idle aim recoil death`): the rig says where415 the parts are, the kind says how they move, and neither knows about the other. Same rig416 → same checksums, which is what makes an agent-written rig reviewable.417- The poser samples destination→source (inverse rotation), so rotations never leave holes.418 It pads the canvas (`--pad`, default 10) and moves the anchor with it.419420- What actually bites at this scale: **14,400 characters per pose** (66x a SNES sprite), and421 `spr_draw`'s one `DrawRectangle` per opaque pixel. Measured ceiling ~60k rects/frame422 (~12 detailed characters). Past that, switch to a texture upload.423424## MFL traps this engine hit425426Beyond the [machin-gamedev](../machin-gamedev/SKILL.md) list:4274281. **Signed overflow is UB the optimizer folds.** `*` and `<<` emit signed 64-bit C ops.429 The textbook 64-bit FNV round `h = (h ^ b) * 1099511628211` collapsed to a constant430 (`INT64_MAX`) at `-O2` — the checksum silently stopped discriminating. Same hazard in431 xorshift64\*. **Keep every intermediate inside int64**: two 32-bit lanes, mask after432 each step, pack as `(hi & 0x7FFFFFFF) * 4294967296 + (lo & 0xFFFFFFFF)`. See433 `fnv_i` / `rng_next` in `engine/00_core.src`.4342. **u8 cstruct fields wrap silently.** `col_mul(orange, 1.5)` came back green. Clamp435 before constructing a `Color`.4363. **Int literals are 64-bit signed** — `0x9E3779B97F4A7C15` is a *parse* error437 ("value out of range"). Top nibble ≤ 7.4384. **`charat` returns a string**, not a byte. Use `byte_at(bytes(s), i)`. The typecheck439 error blames your accumulator, not `charat`.4405. **Multi-assign into a struct field does not parse.** `ns.rng, v = f()` errors at the441 comma. Assign to temporaries first.4426. **One inferred type per parameter, program-wide.** A test helper `ok(flag)` cannot take443 both an int and a bool; write two functions.4447. `Ent{}` (empty literal) zero-fills and works. Non-empty `[]Struct{a,b}` still does not.4458. `_` is not assignable: `_ = f()` is "assignment to undefined variable".4469. **`:=` is FUNCTION-scoped, not block-scoped.** The same name holding two types in447 *disjoint branches of one function* is a hard error, not two block-locals: a `Shade`448 named `sh` in one CLI subcommand and a `string` named `sh` in another failed to449 typecheck, as did `n` used for a slice in one branch and an int in the next. Rename;450 there is no shadowing to fall back on.45110. **NEVER inline a call in an FFI argument list next to an INOUT struct param.**452 `ImageFormat(img, RL_RGBA8())` miscompiles: in a small program the conversion silently453 does not happen (the format stays whatever the file was), and in a large one it454 **segfaults inside the call**. Hoist it: `rgba := RL_RGBA8()` then `ImageFormat(img, rgba)`.455 This cost half a session — the symptom was an importer that worked on one PNG and died456 on another, and the difference was only that the second file needed converting.45711. **A named return's type is inferred from the CALL SITE.** `func f() (v) { v = 1 }`458 used as `if f() { … }` types `v` as *bool*, and every `v = 0` inside the function then459 fails to typecheck — the error points at the function, not at the caller. Write460 `if f() == 1`.461462## Debugging a divergent replay463464`verify` prints `first_divergent_frame`. Re-run `sim --frames <that> --trace` on both465and diff. The usual causes, in order:4664671. Something in the synced half read the view, the clock, or `rand_bytes`.4682. A system iterated `len(scene.ents)` *while spawning* — capture `n := sim_n(s)` before469 the loop, or new rows get stepped in the same frame they were created.4703. Game state kept outside `Sim` (a local in the loop) — it is not checksummed and does471 not replay. The attract bot's waypoint index lives outside `Sim` **on purpose**,472 because on replay the bot does not run at all.473474## Known limits475476The attract bot is a route-follower, not a good player: it clears three floors, rides the477cable and rescues all four hostages, but does not reliably finish the stage. Determinism478is same-binary/same-platform — Spring needs STREFLOP for cross-CPU bit-equality and479ressort has no equivalent. No audio yet.