| name | machin-ressort |
| description | 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. |
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
./build.sh test
./bin/demoman scene game/level1.rml
./bin/demoman sim --frames 9000 --trace --record run.rr
./bin/demoman verify run.rr
./bin/demoman shot out.png --at 600
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
ressort sprite check gunman [--tone]
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
ressort sprite show <name> [--src]
ressort sprite mats
ressort sprite shade game/sil/drone.sil
ressort sprite reshade <name> --mat navy
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]
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
ressort sprite import x.png --native 100 --compare c.png
ressort sprite import x.png --native 100 --compare /dev/null --bench 24
- 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
- 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
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
ressort sprite rig hero.spr --rig hero.rig
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.
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
-
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.
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
. Hoist it: then .
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.
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.