M3 design: verification depth
Status: draft, 2026-09-30. Read with ARCHITECTURE §8, ADR-0004 and research/testing-verification.md §4–§5. M1 and M2 built the ladder's frame: L0 spec, L1 boot, L2 play (tours, bots, asserts, trace metrics), L3 frames (not blank, changed after input, subject present), and a VerifyReport on every proposal. M3 makes the ladder find the bugs a generated game actually has, without anyone writing a tour for them. The test (ROADMAP M3): a planted bug is caught at the right rung, and not before.
What exists, and what's missing
| Gate (research §5.1) | Today | M3 |
|---|---|---|
| Clean boot, determinism | L1: 600 ticks twice, same hash, clean log | as is |
| Solvable | a golden-path tour the Director writes | also a goal-seeking bot with human noise (seek; on a platformer, playthrough: decision 11) |
| Crash-free fuzz | nothing | the monkey on N seeds, with per-tick invariants |
| Invariants | only what a tour's asserts check | engine-side, every tick of every L2 run |
| Reachability, softlocks | the grid solver, for puzzle_grid only | the explorer, for any template that snapshots |
| Readable frames | not blank, subject in view | the subject's size and contrast in pixels |
| Judge | none | L4, grounded questions only (M3b) |
| Web | tools/web.sh by hand | L5 in the ladder: the browser's hash equals native (M3b) |
Decisions
Bots stay input drivers. Every bot is a
GwInputDrivera kit registers in itsbots(the contract kit.melee'sfight_tacticsalready uses). It reads the simulation and never writes it, so every bot run replays from its frames, and a failing run is a replay the person can watch.The monkey belongs to kit.core, so every game has it. It presses the game's own actions (the InputMap's ids), weighted evenly, holding each for a seeded 4–30 ticks, and walks in a seeded direction that changes every 30–120 ticks. Seeds come from
deriveSeed(seed, "bots", "monkey", n). It is meant to be dumb; its job is to reach states a tour never does. As built: the InputMap's ids are not all a person can press. A recipe is made by choosing it on the make screen, which pressesmake:<recipe>, so a kit lists such choices (GwSystem.bot_presses) and the monkey presses them too; without them it never made anything. A stick action is the walk, not a press, and walk directions sit on a 1/1024 grid so a replay's JSON gives them back exactly. On a side-on platformer (no stick) it only presses. Later: in a game whose clicks do something it clicks too, half its walks a click near the player (docs/design/pointer-input.md, "Bots that click").Invariants run in the engine, every tick, in every verify run (
sim --invariants, on by default for L2). A violation is a trace eventinvariantwith the tick, the entity and the rule, and it turns L2 red. The rules, generic across templates:- no NaN or infinite number in any component or stat;
- every body on a tile inside the world (
GwWorldQuery.in_bounds(tile_of(pos))) and not below the ground there by more than a body height (height_at); the flat test double has no edge, so the rule holds trivially there; as built: a side-on world's tile is x and y, not x and z, so the rule asks the world (GwWorldQuery.holds(pos)), and the platformer holds its columns down to two tiles under its bottom row (a crawler over a hole fell forever before); - the entity count under a limit (4096 unless the manifest sets one: a new optional field);
- no event dropped for a chain deeper than
GwEvents.MAX_DEPTH(today only a warning; now an invariant event, so it lands in the report with its tick). Presentation invariants (orphan nodes, memory growth) belong to L3's player runs, not the sim. As built: L1's idle runs check them too, so a game that breaks one while idle is red at L1, and every run stops at its first violation so its replay ends where it broke.
Fuzz is a Playtest, not a special mode:
{ bot: "monkey", runs: 4, ticks: 3600 }. A new optional Playtest fieldrunsrepeats a bot run onrunsderived seeds. The template ships one fuzz Playtest by default, so every game is fuzzed without the Director asking. Runs go through a job pool ofGAMEWRIGHT_JOBSengine processes (default 4): the machine is shared. Runiis onderiveSeed(seed, "runs/i"). As built: every run must be free of errors, broken invariants and failed asserts; an event the Playtestrequires (a field for any source, not only the explorer) needs one run to reach it, and a bot's goals need one run to meet them all.The goal-seeking bot (
seek) is the heuristic agent, one per template family, built from the tour verbs kits already have (goto,use,make,toggle,fight). A Playtest names its goals as data (goals: ["make:item.fire", "reach:tag.shell", "var:shells >= 5"]); the bot works toward the first unmet one by the obvious route, with a reaction delay (seeded, 8–15 ticks) and a fumble rate (seeded, 5%: a wrong direction for a few ticks). "Winnable by a mediocre player" isseekreaching the win on at least 1 ofrunsseeds. It lives in kit.core and asks each goal's kit for the verb, so a new template adds goal kinds by adding tour verbs, not bots. As built: the goals arereach:<place>,near:<arch>,take:<prop kind>,have:<item>,make:<recipe>(have each input it lacks, in the recipe's order, then make),fightandwin;var:conditions need the core to compile goal expressions and wait for that. A kit says how to come by an item (GwSystem.bot_sources: kit.harvest's nearest standing prop that yields it). A goal that fails three plans over leaves the bot stuck, as a person would be, and the report says which goal and why; the CLI issim --bot seek --goals G,.... Later (decision 12): a baremake,survive:<seconds>, andwinthat waits (it had failed after three empty plans, 30 to 45 ticks in, whatever the game did). Later still:seek_click, the same bot with only a mouse, for a point-and-click game (docs/design/pointer-input.md, "Bots that click").The explorer is Go-Explore in the engine (
sim --explore TICKS), because a search that crossed a process boundary per step would be too slow. A cell is (the player's tile at 2 m, the sorted item counts, the set of latches and objective flags, the game mode). The archive keeps each cell's first snapshot (GwSimstate, bit-exact since save v2) and the input prefix that reached it. Each round picks a cell with weight 1/√(visits+1) from a seeded stream, restores it, and acts with the monkey for 60 ticks. It reports: cells, the share of walkable tiles visited, every event id reached with the shortest prefix that reached it (a replay), and the required events never reached. Reachability gate: a win (kit.core'sgame.endwith outcomewin) and every event the Playtest'srequirenames must be reached within the budget, or L2 is red with the unreached list. Dead ends (cells from which many rounds reach nothing new and no progress event) are a signal with their replays, not a gate, until calibrated. Templates whose state can't be snapshotted exactly opt out, and the report says so. As built (a Playtest asks for it withexplore: true; itsticksare the budget):- 1/√(visits+1) alone spent the rounds on cells it had already crossed and never climbed the one ramp of a 40×40 coast in 30 000 ticks (26% of the walkable tiles). The weight is now (1 + the cell's 2 m neighbours nobody has stood on) / √((rounds from it + passes into it + 1) × the cells sharing its progress), Go-Explore's counts with a frontier bonus and a bonus for rare progress; a round is pointed at one of those unknown neighbours. On that coast it finds the win in 40 000 to 70 000 ticks where the win is reachable, and covers 95% or more of the walkable tiles where it is not. The search stops once every required event is reached.
- A dead end is a progress (what the player carries, the variables, the latches, the mode) that at least 32 rounds set out from and none left: the softlock itself, not a corner of the map the search had finished with.
- Snapshots were not exact at first: kit.brain_fsm kept each brain's state machine runner, which
holds its entity, across a load into the running game, so after going back the old runners
stepped bodies that were gone. Its load now drops them. Before searching, the explorer plays
120 ticks, goes back to tick 60 and plays the second half again; a game whose two ends differ
opts out. Every prefix it reports is checked in the tests by
sim --replaylanding on the event's tick.
Readability in L3 is measured, not judged. The player shoots each subject's frame a second time with the subject drawn as a shadow only (
GwView.nodes_of), so the pixels that change are exactly the subject's own, shadow excluded. Its height is the rows they span; how far it stands from its surroundings is the median CIE76 colour difference (ΔE) of its pixels from the mean colour of a ring of background round them. Gates: height ≥ 12 px at 1280×720 (scaled by the frame's height) and ΔE ≥ 10, defined once incore/src/verify/frames.ts. The WCAG luminance ratio is reported beside them and gated nowhere: calibration (below) showed the frames people accepted fail it. As built in M3b: only kit.actors drew subjects as nodes, so the flat games (Burrow Run's hero, Pier Pressure's keeper) measured nothing and L4 would have had nothing to score them against. A flat view now leaves a subject out of the second frame (GwView.leave_out: kit.platformer any live body or thing, kit.grid the avatar forplayer). The check also reports the subject's bounding box (bounds) and the player its archetype (arch): what the judge's answers are scored against and how it names the subject.7b. A threat lost in the dark reads by its lit tell (M3d, 2026-10-01; reversible). At night with no light every colour goes toward black, so a machine's body can't reach ΔE 10 whatever its palette. In the M2 acceptance run, pale plating moved Nightcoast's sentry from ΔE 4.8 to 3.1 (docs/demos/m2-acceptance.md, friction 7). The platform's rule is that a person can find every threat at any hour, and a lit lens does that. So a subject reads by its body, as in decision 7, or else by its lit tell:
- The player shoots a subject whose models have emissive slots (a lens, embers, a carried
lamp's glass) a third time with only their glow off (
GwModelNode.set_glow_offholds them dark whatever level the view sets). The subject's pixels that change are its lit tell, exactly: in every frame measured, the third shot differs from the shot at those pixels and nowhere else. The subject's row gainsunlit, and the check gainstell(pixels, ΔE, bounds) andreadBy(bodyortell). A subject with nothing lit reports exactly as before. Each mesh is listed once now: a carried lamp was listed both as itself and inside its carrier, so it was put back as a shadow and its glass vanished from every later shot. - Gates, beside the others in
frames.ts: at least 6 tell pixels at 720 rows, scaled by area and never under 2 (one pixel is a spark), at a median ΔE of at least 20 from the subject's ring. 20 is twice the body's gate, because a tell is a few pixels. The numbers are in "Readability calibration" below. - Nightcoast (the acceptance pack, unchanged):
sentry_nightreads by its lens, 3 px at ΔE 52.7, andsentry_lampby its lens, 3 px at ΔE 87.2 (body ΔE 3.1 and 3.2). L3 is green, 6 of 6 frames. - Tideline's
roundstayed red under the rule, correctly. Its sentry walks away up the slope and its lens faces away: turning the glow off changed no pixel in the frame, and a brightened crop shows a black shape and no light. The fix was in the game: machine.sentry'sventGlow(default 0) lights the working part's slats in the lens's light, in the lens's surface, so they follow its glow (faint at rest, bright at the tell, dark in death). That serves Tideline's pillar: dodge, then strike the part on its back. Tideline sets 0.75, androundreads by its tell: 13 px at ΔE 30.5 (body 9 px tall at ΔE 2.9). At 0.6 it read at ΔE 24.1, too near the gate for a glow that pulses. A8 still holds: night mean luma 0.0159, +47% with the lamp, and the sentry at noon 0.197 against 0.509. - Reversible: delete the tell branch of
readableand theMIN_TELL_*gates. The measurement can stay as information. Nightcoast's two night frames would go red again; Tideline'sroundwould too unless its body reads. - The alternative is a night outline on machines (the KitRequest in the acceptance pack): the body itself would read at any hour, whichever way its lens faces. Not taken now. It adds a pass to every frame in Compatibility, it lights up the night that the game's "darkness hides you" pillar needs, and a tell is diegetic and costs nothing to draw. If a game's threats have nothing lit and must read at night, that outline is still the way.
- The player shoots a subject whose models have emissive slots (a lens, embers, a carried
lamp's glass) a third time with only their glow off (
The planted-bug corpus is the acceptance test.
core/test/fixtures/planted/<bug>/holds small packs, each with one bug, andplanted.test.tsasserts the first red rung, the diagnostic code, and that every earlier rung is green:Bug First red rung Caught by no_source: the win needs an item nothing givesL0 obtainability (exists) walled_key: the item's only source is walled offL2 explorer reachability runaway: running kicks up dust that never settles, past the manifest's entity limitL2 monkey + invariants (entity_limit) stuck_door: a door shuts behind you and nothing opens itL2 explorer (signal), seekfailsinvisible_enemy: a threat the colour of the groundL3 readability (ΔE) lights_out: nights with no light at allL3 not blank (exists) clipped_text: an opening line too long for the HUD's message line (M3c)L3 text_fits phone_label: a HUD goal line 400 pitches wide and a tip line above the middle, apart on a monitor; on a phone held upright the goal wraps onto the tip (phone-text.md §7; until kit.ui reflowed text, the goal was cut there)L3 phone_text_fits (over) crossed_labels: a HUD tide line 6 pitches under the goal line, given compact places a line apart: over it on a monitor, clear on a phone (phone-text.md §7)L3 text_fits (over) unopened_app: a slate app in place of carry whose two-row list and note pane stand apart on a monitor's slate and run into each other on a phone, where the note, taller than the list beside it, leaves it no room; no tour opens it (decision 13; until kit.ui reflowed text, a one-line heading cut on the phone; until it fitted lists, a ten-row list the phone now moves under the note)L3 phone_text_fits (over), on the frame L3 opens it in tight_landing: the hero's run speed turned to the knob's top, over two-tile stones between spike strips (decision 11)L2 the template's playthrough: give_ups over median ambush: the sentry starts its round at the player's own spawn (decision 12)L2 the template's first minute: survive:60 and player_deaths over median As built, three bugs had to be planted otherwise, because the kits refuse the bug as first written:
runaway: no speed sends a body off the world. kit.move refuses any step onto a tile out of bounds, up or down a cliff, or more than a tile at once, and the Expr VM returns 0 for a division by zero and clamps sqrt, so a speed knob gives a stuck body, never a lost one or a NaN. The planted runaway is a spawn that never stops: running kicks up dust that is never despawned, the monkey runs, and the entity count passes the manifest'ssim.maxEntities(64). An idle L1 never runs, so it is green.stuck_door: no kit has a door, and a solid body is no wall (kit.move lets a body through another when it can't push it out, as in a corridor). The planted door is one-way travel: on a beach under a cliff with no way up, taking the flint brings the cliff down; the player is knocked out and wakes on the clifftop (kit.melee's downed and respawnPlace), with no way back to the driftwood. Driftwood first and the fire can still be made up there, so the explorer is green and names the dead end; seek follows the recipe's order, takes the flint first, and is stuck. Listed driftwood first, seek makes the fire.invisible_enemy: a machine is drawn as a dark mass (the found material), so a sentry painted in the turf's palette colour still reads at ΔE 42. The planted threat is a mimic: a rock (the made material, like the ground) painted to match the lit turf.walled_keyandstuck_doorshare the planted coast (40×40, a cliff with no ramp); the test proves the explorer is not trivially red by givingwalled_keya ramp and watching it reach the win.tight_landingwas planted as a frame-perfect jump first, and the kit wouldn't have it: coyote time, the jump buffer and the jump cut forgive a gap at the edge of the hero's reach (a casual player crossed the widest 4-tile pit the search could prove with about one hurt a run), and a landing window narrower than a tile is one the search's moves can't prove either, so it was red at L0. The planted level is one a Director could make: two-tile stones between two-tile spike strips, fine at the template's run speed of 7.5, withtune.run_speedturned to its top, 14. Bake's search still lands every stone (the witness is 227 ticks, and the routes Playtest replays it), so L0 and L1 are green; at that speed a person slides off every landing. On seeds 1–8 a casual player gave the level up in 8 runs of 8, hurt 16 times a run (median), most at the first strip; with the knob back at 7.5 it finished 7 of 8, hurt 4.5 times a run, and the gate is green.
Text a person can't read is red in L3 (M3c). In the M2 acceptance run a Director wrote an opening HUD line too long for its widget and the player showed "Make one befor…"; nothing in the ladder noticed. The engine knows exactly when it happens, so it says so instead of a model guessing from pixels:
- At every tour shot the player asks each view for the text it drew (
GwView.texts). kit.ui answers for the title, the HUD, the open app and the slate's own text: per text, the Screen and widget ids, the text as given, what was drawn of it, whether that is all of it, where its ink lies in the frame (a capital's top to a descender's foot) and the face's body height there. A label computes the lines it draws once (shown_lines) for both the draw and the report, so the report is the draw. The rows go in the shot's manifest beside the subject measurements (files[].texts[]). Reading them never writes the simulation. - kit.ui clips nothing to a box (no Control clips its contents): a label's line is cut with
"…" to its width, a wrapped label draws as many lines as its height holds, and everything
else (hints, buttons, a frame's title, a list's details) is drawn whole and may run past
its box. So "doesn't fit" is: cut (
whole: falseon one line), lines dropped (lines: [drawn, of]), or ink off the frame. Text that overruns its box but stays in the frame is whole and readable, unless it runs over another text (below). text_fits(frames.ts) is red for any of those, naming the screen, the widget, the text, how much of it showed (shows 69 of its 109 characters, ending "…lint keeps the sentrie…") and the fix ("shorten it, or give the widget room"); the frame'stextslists them.text_sizeis red for a body height under 18 px at 1080 rows, scaled by the frame's height (6 at L3's 360): Xbox Accessibility Guideline 101's minimum for PC text, measured as it measures (the highest ascender to the lowest descender). It is a gate because every accepted frame passes it (below). At L3's 640×360 the pixel face can't be drawn under 9 px (pitch 1), so it bites only on larger shots (a fixed pitch-1 style at 1280×720 is 9 px of the 12 needed).- The Director sees it twice:
playtest's digest has atextlist (each unreadable text once, with the frames that show it), andpreview's caption ends with it. - kit.grid's and kit.platformer's own captions (a level's name and turn when the game has no
HUD, the "paused" banner) are drawn with Godot's fallback font, not kit.ui; they are reported
since 2026-10-01 (
GwView.plain_text_row: that face's body is about its em). - A phone shows the frame a fifth to a quarter the size a monitor does, so text that passes
here can be too small there, or cut: kit.ui grows its pitch to 11 pt on the person's screen
and lays each Screen out in the smaller room that leaves (phone-text.md).
As built (2026-10-02), L3's phone pass (phone-text.md §7): a tour whose shots show text is
played again in the player drawn for a phone held upright (
--phone 390x844: the frame at the size a person's run renders, 1920×1080, placed where the touch layout puts it beside the game's controls, so 0.2031 points a frame pixel), its subjects left out. Each of those shots is a frame of its own (phonesays the phone, the frame on it and the points) with two checks, judged bycore/src/verify/screen-texts.ts, the rules the web proof's phone visit (touch text) judges by:phone_text_fits(none cut, short of lines, off the frame, over another text or under a pane) andphone_text_size(every body at least 9.9 CSS pixels, 11 pt). A red phone text says so (ui.hud goal on a phone held upright: …) and gives the phone's fixes: wrap, shorten, or acompactplace. The phone run must end on the monitor run's state hash, or L3 is red: the simulation read the presentation. - As built (2026-10-02), over and under on a monitor (phone-text.md §7):
text_fitsis red for a text whose ink crosses another's or that a pane drawn after it covers (kit.ui'sunder), judged byscreen-texts.tsitself on the monitor's frame as a screen of one point a pixel (frames.tsmonitorScreen), so a monitor's frame and a phone's are judged alike: a pair listed once, at the first; a line on its way out skipped; touching is not crossing. The decision was to extendtext_fits, not to add atext_clear:phone_text_fitsalready means cut, lines, off the frame, over and under, so one name means one rule set on both screens, and all five are fixed in the Screen's layout (a check of its own is for a fix of its own, as size and contrast have). The red text names the other (overlaps ui.hud tide "The tide turns at dusk.", the frame'stexts[].over) and the fix: the widget's offset or size, as a compact place moves it on a phone only, which is how this was missed: the phone's fix for an overlap is a compact place, and it left the monitor's overlap where it was. Before this, L3 was green on the plantedcrossed_labels. - As built (2026-10-02), the Director's
previewtakesphone: true: the frame as L3's phone pass draws it, with its two checks, so the Director sees a Screen on a phone before it proposes it (phone-text.md §7). - As built: a HUD message line faded by the frames drawn (
_processand wall time), so while a shot held the game still the line kept stepping down its ramp. The second frame a subject is measured in then differed from the shot by the line's pixels, and the subject took them in: in Tideline's proof tour the sentry measured 140 px tall at ΔE 7.2 innoticedon one run and 155 px at ΔE 7 intellon the next (red both times, for a line of text far below it), and the wanderer 156 px inwake. Whether a line was up in a shot, and so whether it was reported, depended on the machine's speed. A line now ages by the game's ticks (the screen tells it the time on every refresh), so a shot stands still and every run draws the same frame: the sentry intellmeasures 34 px at ΔE 21 and the wanderer inwake24 px. Its birth was still the first refresh that showed it, so a tool run stepping without frames drew it bright at the next shot; it now ages from the tick it came up (fast-runs.md §3).
- At every tour shot the player asks each view for the text it drew (
A Playtest's asserts are checked by the engine (M3e, 2026-10-01). The core used to do it: it added
save <slot>to a tour, read the save back from the engine's user folder and ran a TypeScript port of GwExprVm over it, withcountanddistas its only kit functions. A bot or idle run had no tour to save from, and kit functions (platformer_done,grid_solved,item_count) and the game fields kits add (game.hour) came backunchecked, so Burrow Run's and Pier Pressure's asserts proved nothing (M4's routes tour carried the proof instead). Now the game answers them itself:
sim --asserts TEST: where the run ends, however it ended (a tour done or failed, a bot done, the ticks out, an invariant broken), the engine reads Playtest TEST'sassertsources from the bundle, takes each one's code from exprs.json and runs it on its own VM (GwAsserts) in the roots a Playtest's expressions are typechecked for: self (entityplayer), game (with the fields kits add), var and tune. Each row is{src, value, pass}, withoperandswhen a comparison fails, or{src, error}. The rows report; the ladder judges. With--exploreit is a usage error: a search has no one end.- Pure only, twice over: the VM gets
GwVocab.pure_only(), as a view's does, and L0 refuses an assert that calls a function its kit.json doesn't mark pure (expr.impure_in_assert). The check runs before the state hash is taken, so a write would show. The toy bundle'stest.toyasserts onroll, which draws from a stream: with and without--assertsthe run ends on the same hash (sha256:f181b4db…), and the engine test that says so goes red when GwAsserts is given the game's whole vocabulary (roll draws, the hash moves). - In L2 a row that holds passes. One that is false fails, with both sides when it compares
(
it is false at the end (left 0, right 1)). One the game can't run (root self is not available here) fails too: the game can't answer it, so it isn't proven.uncheckedis left for a run that gave no verdict. A run that stopped short (a failed tour) is red for that, and its asserts' rows say what they were where it stopped. - An explore Playtest with asserts is red at L0 (
playtest.assert_explore); its gate isrequire. - Deleted:
core/src/verify/asserts.ts(the VM port, the save reader, the user-folder lookup), its tests, the ladder'suserDiroption andENGINE_GAPS.asserts. The conformance vectors (testdata/expr-vectors.json) never needed the port: the compiler's test checks every vector's value with its own small evaluator, and the engine'sgw_expr_vm_testruns them on GwExprVm. The run fingerprint no longer hashescore/src/expr: the compiled code is in the bundle, whose hash is in every cache key. - On the shipped games (
gamewright verify, L0–L2): Burrow Run'splatformer_done(...)andoutcome() == 'win', and Pier Pressure'sgrid_solved,grid_level,grid_turnandgrid_wonasserts, alluncheckedbefore ("no function platformer_done"), now pass. Tideline's proof gainsitem_count(self, 'item.driftwood') == 0anditem_count(self, 'item.scrap') == 2, which pass where its tour ends (tick 4591, the same state hash with and without--asserts). Planted wrong asserts fail with what the game computed:platformer_deaths() >= 1on Burrow Run's routes (left 0, right 1),grid_turn() == 3on Pier Pressure's undo (left 0, right 3), anditem_count(self, 'item.driftwood') >= 3on Tideline's proof (left 0, right 3);core/test/verify/asserts-engine.test.tskeeps the first two. The tiny fixture's inline Playtest ends with a score of 2, not the 3 a test comment claimed: the step at tick 120 has not run whenawait tick>=120lets the tour end. - The first full run found an engine bug the port had hidden: kit.core's
countcounted every tagged entity, dead ones too, where its kit.json promises live ones, so Nightcoast'scount('machine') == 0failed on the sentry's body.countnow counts live entities. No rule of a shipped game counts anything that can die, so no game's run changed. - What an assert can't say yet: it reads self, game, var and tune, so a kit function about
another entity (
brain_state(e),suspicion(e)) can only be asked of the player. Naming an entity by id in an expression would need a new root or function.
- A platformer is played by a person's bot too (2026-10-01). L2 played platformers only
with their witness routes, the reachability search's exact inputs replayed perfectly. That
proves a level can be finished. It can't say whether a person could, how often they are
hurt where, or how long a level really takes: Acorn Rooftops' Director saw 11–12 s witnesses
and could only guess "a minute per level". Its first reward was also the final win, since
the event table didn't count a collected acorn. kit.platformer's
playthroughbot plays every level in order the way a person would:
The route as moves (
GwPlatRoute). The search plays moves from a stand-still to the next (a walk, a jump with its run-up and hold, a climb, then letting go until the body stands still), so a witness splits back into them. Standing still, the bot looks where it is: at the start of the move it expects, of a later one (it got further than it meant), or of an earlier one (it fell back: a retry). Where the route never stands, it finds its own way back to the route or to the exit with the core's search ported to the engine (GwPlatSearch: on the rig's level it finds the core's witness tick for tick).The jump a person would make (
GwPlatComfort). The search keeps the quickest way, so its witness hops a spike pit with the shortest hold that clears it, and a bot that copied it was hurt on hops a person clears without thinking. For each jump of the route, every jump the search knows in that direction is played from the move's start; those that land where the route goes on are played again under the skill's own errors (the press and the release 1.5 σ early, on time and late, from the line-up error behind and ahead: 18 plays), and the move that lands most often is played, the witness's own on a tie.Hands (
GwPlatHands,GwPlatSkill, in milliseconds so a skill means the same at any tick rate): a look before each move, a line-up never quite on the spot, every key down and up a seeded error early or late (the sum of four uniform draws: exact on every platform), and now and then a fumble (the direction slips, the wrong way, a jump let go at once). It waits while a move would meet a moving hazard, looking again every 6 ticks; when standing still isn't safe either, it searches for a way past from where the hazards are now. Hurt, it takes a breath at the checkpoint and tries again. Stuck (its patience in tries at one stretch without getting further, or its minutes on a level), it gives the level up and presseslevel:<next>as a level select would, so every level is measured; after the last it stops. Streams by name:bots/playthrough/<level>/react,timing,fumble,lineup.Skill Look before a move Breath after a hurt Key timing σ Line-up Fumbles Patience novice 250–450 ms 0.7–1.4 s 60 ms ±0.35 tile 8% 6 tries, 4 min casual (default) 180–320 ms 0.5–1.0 s 35 ms ±0.2 4% 10 tries, 5 min skilled 120–220 ms 0.3–0.7 s 17 ms ±0.1 1.5% 20 tries, 8 min perfect none none 0 0 0 — A look of a fifth to a third of a second is a person's choice reaction time; 35 ms is two ticks at 60 Hz, inside the 100 ms of coyote time and jump buffer the template gives.
perfecthas no hands at all and plays each witness tick for tick (an engine test pins it, and Acorn Rooftops' perfect run ends on tick 2113, its three witnesses end to end).What is measured comes from the trace, not the bot, so any run of a platformer has it (a tour's, a bot's, a person's recording):
Metrics.levels, each level from when it began to its exit, to the next level, or to the run's end, with its time, its hurts and the tile and cause of each (platformer.hurtcarriestx,ty), its deaths (platformer.diedcarries the cause, and is a death of the player indeathsnow), and what it collected. The bot adds its retries and why it gave a level up. Playtest metric ids:levels_done,give_ups,hurts,level_hurts_max,level_deaths_max,level_seconds_max,retries. A range may be checkedovera Playtest's runs (medianormean) instead of on each: a person's bot is noisy by design, and one unlucky run shouldn't turn a game red. A failed range over runs is red on the Playtest's first run. The digest gainslevels: per Playtest and level, over its runs, how many finished, the median time and range, the median and most hurts, deaths, where most hurts happened, and who gave it up and why.The first reward of a game of levels is a thing collected (
platformer.collected), a level's exit (platformer.exit) or a solved grid level (grid.won), not only the final win: Acorn Rooftops' first reward went from its last den to the first acorn, at 0.43 s.The template plays it by default:
tpl.platformer_2dshipstest.playthrough, a casual player on 8 seeds, 108 000 ticks, gatedgive_upsover median, max 0 (most runs finish every level). A game replaces it by id to change the skill, the runs or the gates; L0 refuses askillwith no bot (playtest.skill_without_bot) or with a bot that doesn't read it (playtest.skill_unread; a kit.json bot saysskill: true).The numbers, 20 seeds each (seeds 1–20), seconds from each level's start to its exit (median and range), hurts per run (mean and most), runs that finished:
Level (witness) skilled casual novice Rooftop Hop (11.3 s) 13.0 s; 0.1 hurts; 20 of 20 14.5 s (13.8–26.5); 0.3, at most 2; 20 24.0 s (15.4–53.4); 1.2, at most 5; 20 Chimney Spikes (12.1 s) 16.5 s; 0.2; 20 21.8 s (17.8–28.8); 0.9, at most 3; 20 30.9 s (19.3–54.5); 2.8, at most 11; 19, 1 gave up Crow Ridge (11.8 s) 23.2 s (17.0–74.0); 1.2; 20 34.0 s (23.4–90.5); 2.9, at most 11; 20 54.2 s (25.1–87.8); 5.1, at most 12; 16, 4 gave up Burrow Run's Meadow (4.0 s) 5.2 s; 0; 20 5.8 s (5.5–6.2); 0; 20 8.0 s (7.8–23.0); 0.6; 20 Burrow Run's Caves (3.9 s) 4.8 s; 0; 20 5.5 s (5.0–10.7); 0.1; 20 6.8 s (5.9–12.8); 0.1; 20 Acorn Rooftops takes a casual person about 70 s (15, 22 and 34 s by level) and a novice about 110 s, not three one-minute levels, and it gets harder level by level as its brief says. Crow Ridge is where people are hurt: 22 of the casual player's 58 hurts there are the first crow (
crow.22.4) at tile (15, 4), where the jump onto its roof lands; on Chimney Spikes the most common spot is the fall at column 40 (5 of 18).Cost: about 5 s of engine time per run of Acorn Rooftops (the comfort plays dominate); its L0–L2 with the template's 8 runs takes about 25 s on 4 jobs.
Not done: it takes no detour for a collectible; it stands still between moves, as the witness's moves do, so its times are an upper bound for a fluent player; it doesn't watch for hazards during a look or a breath; comfort leaves moving hazards to the waiting. The presets are set from what is known of people's timing, not yet calibrated against recorded people (L6).
- An action survival game is fuzzed and played by a newcomer by default (2026-10-01).
Decision 4 said the template ships a fuzz Playtest; until now only the platformer shipped
anything. Nightcoast's Director wrote a good seek Playtest by hand (
test.salvage), and Tideline has none: the monkey,seekand the explorer ran only when an author wrote them.tpl.action_survivalnow ships two, which a game replaces by id:
test.fuzz: the monkey, 4 runs of 3600 ticks (a minute) from seed 1, exactly decision 4's numbers. Every run must be free of errors and broken invariants; nothing else is gated, since the monkey is dumb (on Nightcoast the sentry downs it in 5 of 20 runs by day, when it blunders into it).test.first_minute:seekwith goals["make", "survive:60"], 4 runs, at most 10 800 ticks. A newcomer gathers for the first thing the game lets it make and makes it, then stands where it is until a minute of play has passed. Gated: one run must meet both goals (decision 4's rule for goals), so the first thing is made within 3 minutes, the template's ownfirst_rewardacceptance; andplayer_deathsover the median, max 0, so most runs are not lost in the first minute. A game whose opening is a fight replaces it with goals that say so (["make", "fight", "survive:60"]).Two goals were added to
seekfor it, both generic. A baremakeis the first thing a person would make: of the recipes made by hand (no station), the one needing a tool last, then the fewest items still to come by, then by id; its row says what it made (made rcp.lamp). A game with no recipe has nothing to make, and it is met at once.survive:<seconds>stands and waits; with it among the goals, the player downed or killed, or the game lost, before those seconds fails the goal being worked on, whichever it is (lost 5.3 s in (the player downed), before survive:60 was over), and the run stops there, so its replay ends where the player was lost, as an invariant's does.winwaits too: it had failed after three empty plans, 30 to 45 ticks in, whatever the game did.No gate on
time_to_first_reward_sec. It was the obvious one, and the template's acceptance lists it, but it can only be measured where the game has a reward: a game made from the template alone has no recipe and nothing to take, so every proposal fromcreate_gameto the first gather-and-make would have been red, and so would the fixtures and planted packs built on the bare template. The first reward is held through the goals instead (made within 3 minutes on one run), and the report's signal still flags a first reward later than 20 s.No explorer. Measured on both games, 60 000 ticks each: Nightcoast's search took 50 s of engine time for 970 cells and 30% of the walkable tiles and never reached its win, which is dawn after a night, 34 800 ticks of clock from the start on one branch (a cell carries no hour, so every round goes back to an early snapshot); Tideline's took 107 s and covered 2.9% of its 192×192 coast without making a fire. As a default it would be red on Nightcoast and too slow for a Director's turn on both. The explorer stays a Playtest an author asks for on a small world (
walled_key,stuck_door).A bug the calibration found: on Tideline, 7 of 20 seek runs failed
makewithnothing the key can take here. Theuseverb pressed the key at once, and a goto stops within 1.1 m of a prop, not always facing it; a prop behind or beside the walker is out of the key's 150° arc.usenow turns first (aim, no step, at most 30 ticks) to the nearest thing in reach the key could work (GwHarvestSystem.workable_near), as a person would. Only ausethat failed before plays differently: every scenario of Tideline's and Nightcoast's own (5 and 49) ends on the state hash it ended on before. With it, 20 of 20.The golden path is the game's own. The ladder ran its first scenario twice for determinism, and Playtests sort by id across template and game, so
test.first_minutecame before Nightcoast'stest.first_playableand took its place. The golden path is now the game's own first Playtest that plays a path (goldenIndex), or its template's first when it has none. A platformer's own routes are its golden path again, not the playthrough's first run.Calibration, on the seeds the template runs and 16 more (
runSeed(1, i), i < 20), each a different world:Game makemetMade at First reward Lost in the minute Engine time a run (CPU) Tideline ( rcp.campfire)20 of 20 6.3–45.5 s, median 10.8 0.8–3.5 s, median 1.8 0 of 20 9–32 s (8 of the seeds) Nightcoast ( rcp.lamp)20 of 20 3.9–7.7 s 0.8–2.8 s, median 1.4 0 of 20 4–6 s the t6b coast ( ambushfixed)4 of 4 0.15–0.22 s (it carries the inputs) 0.1–0.2 s 0 of 4 ambush4 of 4 0.15–0.22 s 4 of 4, at 5.3 s The fuzz, 20 seeds each: no error and no broken invariant in 40 runs, 4–7 s of engine time a run. Margins: the goals need 1 run of 4 and 40 of 40 met them; the slowest
make(45.5 s) is a quarter of the 3 minutes; the deaths gate needs 2 of 4 runs lost and none of 40 was. Walking is what costs on Tideline's big coast, about a second of engine time for each second walked (not profiled yet), so its slowest run, 45 s of gathering, took 32 s.Time added to L2 (L0–L2 through
runLadderon 4 jobs, the library with and without the two Playtests, alternated twice, on a shared machine at a load of 22 to 28 on 24 cores):Game Scenarios Without With Added Tideline 5 → 13 51.3, 53.3 s 84.0, 80.0 s about 30 s Nightcoast 49 → 57 67.5, 61.0 s 73.3, 79.3 s about 12 s Tideline's 30 s is its four newcomers walking its big coast (12 to 16 s each) beside four monkeys (5 to 7 s each) on a pool of 4. It stays well inside L2's 600 s budget, and a proposal pays it once: its base's runs are cached. If a Director's turn needs it shorter, the lever is
runs(3 newcomers still let the median gate one loss), not dropping a check.The planted
ambush(decision 8's table): the t6b coast with its sentry's spawn rule moved fromround_a, seven tiles off, tospawn. L0 and L1 are green (nothing breaks, whoever is downed), and so are the game's owntest.tactics(from the start, tactics beat a sentry that is right there, in 5 s) and the template's fuzz. A newcomer makes its campfire and is downed 5.3 s in on every seed: red at L2 ontest.first_minutealone. With the sentry back atround_a, all four runs hold the minute and L2 is green.Not done: the newcomer doesn't fight back or run while it gathers or waits, so a game whose patrol crosses the spawn in the first minute is red even where a person would fight it off; replace
test.first_minutethere. It doesn't hold out through a night: that is 34 800 ticks of Nightcoast, 30 s a run, and the win is a game's own goal for its own seek Playtest (["make", "survive:600"]orwin).
- Every Screen the game has is shot, not only the ones a tour opens (2026-10-02). L3 shot a game's text only where its tours shot, and no tour can open a slate app (an app opens on a key event, and a tour presses through the input frame, never through Godot's input). So Tideline's and Nightcoast's carry and make apps and Thornwick's carry app, and any app a Director adds, were never shot: text cut, over other text or too small there was seen first by the deploy's phone visit, if it happened to open one. The player can now hold a Screen open by id, and L3 opens each one no tour shot.
Which Screens there are. kit.ui shows a Screen three ways, all from the manifest's
flow: the title (flow.title, while kit.core is on its title), the HUD (flow.hud, always in play) and the apps (flow.apps, else every Screen withframe: slate). A pause menu or a level select would be a fourth way kit.ui doesn't have yet; a Screen in none of these is drawn by nothing. The title is the gallery's menu: its hints are its choices (Pier Pressure's restart, undo, next level). The engine lists them itself: every player result carriesscreens(GwView.screens: kit.ui's title, HUD and apps, in that order, each with how a person reaches it,{id, as}:title,hudorapp), so the core never repeats kit.ui's rule for which Screens are apps.What
--screen IDmeans for each (the player's option, and a tour'sscreen IDverb from there on;GwShownScreenasks the view that lists the Screen,GwView.show_screen). Each is shown as a person reaches it, never as a state the game is not in:- an app: the slate opens on it as its open key opens it (
GwUiView.open_app), the world veiled behind, the key strip naming the menu's keys (on a phone, its controls); - the title: drawn as kit.core's title draws it, the HUD hidden behind it, over the game where it stands. Its widgets read only the game's name and description (every gallery title), so it says what the title says at boot; only the world behind the veil is where the run stands rather than the boot's spawn;
- the HUD: what play shows anyway, so
--screen ui.hudis the game as it stands; - a HUD part gated by a
when(an end card:outcome() == 'win'; Thornwick's quest line while no talk runs) is not forced. Forcing it means writing the simulation (the outcome is state: the run would end on another hash) or lying to the Screen's expression VM, and awhenis any expression: there is no honest way to makevar.quest == 1 and not var.has_hatchettrue, and a win card forced over a game still in play shows its other values (score, time, the level) wrong beside it. An end card is shot by a tour that ends the game: a Playtest that plays to the win takes a shot there (Burrow Run's look tour,complete), and its text is judged like any other.
- an app: the slate opens on it as its open key opens it (
Presentation only. kit.ui opens an app, draws the title or closes them by reading the simulation, as every refresh does; the slate's hold on the input is the devices' business, and a tool run's input comes from its tour. A tour with
screenlines ends on the same state hash as without them, and a shot afterscreenalone is byte for byte the frame without (Tideline at tick 60, seed 7: sha256:5e38ea…, both ways). Tests:gw_player_test.gdtest_a_tool_run_holds_a_screen_open_and_plays_the_same,gw_ui_view_test.gdtest_a_screen_is_shown_by_id_as_a_person_reaches_it,gw_tour_test.gdtest_screen_holds_a_screen_for_the_host_and_steps_nothing.Why a tour verb too, not
--screenalone: L3 shoots every uncovered Screen of a game in one run on the monitor and one on the phone (screen ui.carry; shot ui.carry; screen ui.make; shot ui.make), where--screenalone would boot the game twice per Screen.L3's Screen pass (as built,
core/src/verify/shots.tsrunFrames). After the tours and their phone runs, L3 takes the apps and the title the player runs listed and drops each one whose text any shot reported. The HUD is never opened: every shot of play shows it, so a HUD no shot reported text from has none to judge. A Screen left over is opened: one tour L3 writes (<frames>/screens.tour:wait 1, thenscreen IDandshot IDfor each) is played on the monitor (640×360) and, its shots showing text, on the phone (--phone 390x844), the same two runs a tour gets, so the phone's run must end on the monitor's state hash here too. Each shot is a frame of its own, named by the Screen, withopened: ID(Frame.opened,schemas/v1/verify.schema.json),not_blankand the text checks (text_fits,text_size,text_contraston the monitor;phone_text_fits,phone_text_sizeon the phone). A red one readsui.carry, opened by L3, on a phone: …, the digest lists it the same way, and a green rung says what it opened (15 frames and 15 on a phone (ui.carry, ui.make opened by L3: no tour shot them), every check green). One shot a Screen, a second into play at the spawn: the pass judges a Screen's layout, which the state behind it doesn't move. L4 never asks about these frames (no subject is measured in them, and a slate is no new player's opening); a presentation-only proposal pairs them before and after like any other.What it found on the gallery (L0 and L3 on fresh copies of the eight games, 2026-10-02). Tideline and Nightcoast were red on both their apps, for two reasons, and neither was the games' data; the preview of an app found a third:
- kit.ui reported text the slate veils. An open app's slate veils the whole frame
(
tokens.veil, 73% to 80% dark in the gallery's Styles) before it draws the device, so the HUD around a monitor's device (the clock,IN HAND) is dimmed past reading on purpose.GwUiView.textsdropped only the HUD text the device stands over, so the rest was judged as text to read:text_contrast: ui.hud clock: "16:31" stands 1.4:1andui.hud hand: "IN HAND" stands 1.2:1. With an app open it now reports the slate's and the app's text alone (gw_ui_texts_test.gdtest_the_view_reads_the_hud_and_the_open_slate, red before). A phone never showed it: there the device is all glass and stands over the whole HUD. - A recipe you can't make yet was drawn faint. The make list drew a row short of items in
the Style's faint tone, label and detail: Tideline's
Campfire shortand Nightcoast'sDriftwood lamp shortstood 1.9:1 on the glass, under WCAG's 3:1, and at 640×360 the name is barely there. The person has to read it to know what to gather. kit.ui now draws such a row in the dim tone, the tone of the device's other secondary text (0 of 1 ready, the key strip's words), still a step under a ready row's ink (GwUiList). Not the Style: raising Tideline's faint phosphor would have brightened every outline on the slate and HUD that uses it. - The slate ran off a 960×540 frame (found by the Director's preview of an app, which draws
at 960×540). kit.ui fitted the Style's pitch to the frame rounded to the nearest whole, so
a pitch of 2 at 720 rows was 2 at 540 (1.5 rounded up): a room of 480×270 pitches for
Screens designed for 640×360, and the slate, sized for that room, ran past the frame's
top (
slate tabs: "PACK" runs off the frame's top edge, the frame judged blank, all glass). It now rounds down (GwUiTokens.fit_to;gw_ui_flat_test.gd, red before), so a Screen never has less room than its Style designs for. L3 (360 rows), the phone and the web's frame (1080) are whole steps for every gallery Style and draw what they drew; a 960×540 preview of a Style with 2 at 720 or 3 at 1080 draws its UI at 1, as L3 does at 360, and so does a desktop window between steps (900 rows: 2, not a 3 that ran the slate off it). After all three, every gallery game is green at L3, every opened frame read:
Game Screens it shows Opened by L3 L3 frames Tideline title, HUD, carry, make carry, make 15 and 15 on a phone Nightcoast HUD, carry, make carry, make 8 and 8 on a phone Thornwick title, HUD, carry carry 17 and 17 on a phone Fog Signal title, story (its HUD) none: its tours shoot both 11 and 11 Burrow Run title, HUD none 10 and 10 Pier Pressure title, HUD none 4 and 4 Acorn Rooftops HUD none 1 and 1 Lamplighter none (kit.grid's header; its manifest names no Screen) none 1 and 1 - kit.ui reported text the slate veils. An open app's slate veils the whole frame
(
Cost. Two more player runs, for a game with a Screen no tour shot, each a boot, a second of play and one shot a Screen. Engine seconds inside one
tools/heavy.shslot (GAMEWRIGHT_HEAVYempty), and wall seconds as the core runs them, each run gated by heavy.sh on a box shared with other sessions (load 30 to 60 on 24 cores):Game Tours' runs (as before), one slot Screen pass, one slot (monitor + phone) Screen pass, gated Tideline 91.2 s 16.0 s (8.6 + 7.4) 56.8 s Nightcoast 29.5 s 11.9 s (4.7 + 7.2) 55.0 s Thornwick 51.9 s 11.2 s (4.5 + 6.7) 51.8 s the other five 0 0 The engine's share is a boot and two frames; most of the gated time is heavy.sh's wait for a slot (three readings of free memory 10 s apart, fast-runs.md §6). One tour for every Screen keeps it at two runs however many apps a game has.
Planted (decision 8's table):
unopened_app, the action survival template with its ownui.packin place of the apps: a list under a one-line heading 440 pitches wide. A monitor's slate gives an app about 460, so the heading is whole there; an upright phone's slate is all glass at twice the pitch, an app has about 300, and it is cut (Everything you carry, what each thing does, and how mu…). Its tour, like every tour, never opens the slate. Before the Screen pass L3 was green on it (the planted test failed:expected undefined to be 'L3'); now it is red at L3 alone,1 of 4 frames red; ui.pack, opened by L3, on a phone: phone_text_fits: ui.pack heading …, its tour's frames and the app on the monitor green. With a heading short enough for a phone it is green. The Director'spreviewwithscreen: ui.packsays the same, whole on a monitor and cut on a phone (run-engine.test.ts).Not done. An end card shows only where a tour reaches it. Every gallery game's tours now shoot theirs (Acorn Rooftops' every-level route since 5c8b8e1, Pier Pressure's witness since 2026-10-03: "EVERY LEVEL SOLVED", green on a monitor and a phone), but a game whose tours stop short of its end leaves its card unjudged. A pause menu or level select needs a way into kit.ui first; it would be listed in
screensand opened like an app. The slate's note on a phone (fixed 2026-10-03): the line an act leaves shared the key strip's row, and on an upright phone the keys' words left it about ten characters (slate note … "The tide is out. The light is going." shows 10 of its 36 characters, seen in a preview that held Tideline's make app open from the boot, when the game's first line came). While compact the slate now keeps a row for it above the strip, whole on a phone (test_on_a_phone_the_note_has_a_row_of_its_own). L3 andpreviewstill open an app a moment into play, as a person does, so neither judges the note. A Style with no UI tokens draws at kit.ui's default pitch, fixed for 1080 rows, so on L3's 640×360 an app's slate runs off the frame (the plantedinvisible_enemy, on the t6b Style: its opened carry and make are red there and green on the phone). That is what such a Style draws below 1080 rows, as the schema promises (left out, the pitch is the same pixels at every size). Since 2026-10-03 the slate's own row comes first and says so:slate tabs: "CARRY" runs off the frame's left and top edge (the slate is bigger than the frame: give the Style's ui.tokens a pitchHeight …), and the app rows it pulled off with it are counted, not listed.
Readability calibration
The gates were set on the frames people accepted before they gated anything: both kit.actors
proof tours (engine/tests/fixtures/t6b/proof.tour and world/proof-world.tour), shot again by the
player at 1280×720 and at L3's 640×360. At 1280×720 the 13 frames are pixel for pixel the accepted
docs/images/m1/t6b-*.png. That is 44 subject measurements (the wanderer and the sentry):
| Measure | Lowest of the 44 | Gate | Red under the gate |
|---|---|---|---|
| Height, px at 720 rows | 62 (the wanderer dodging) | ≥ 12 | 0 |
| WCAG luminance ratio of mean luminances, as first designed | 1.00 (the wanderer on lit turf) | ≥ 1.6 | 26 of 44 |
| Median ΔE (CIE76) from the ring's mean colour | 14.9 (the dead sentry) | ≥ 10 | 0 |
The wanderer is a mid-tone figure on mid-tone turf: people read it by hue, shape and its shadow,
at a luminance ratio of 1.0. A luminance gate on mean luminance would have failed 26 accepted
frames, so the measure changed to ΔE, which counts hue and lightness together, and the
luminance ratio stays in the report as information. The planted invisible_enemy (a mimic
painted to match the lit turf) measures ΔE 4.9; the proof tours' sentry measures 14.9 to 53.4
and the wanderer 18.9 to 57.7. web/public/frames/ holds an overview, the style gallery and a
UI screen: no tour subjects to measure.
Lit tells (decision 7b). The same two tours were shot again with the tell pass, at both sizes, before the tell gated anything. The 13 frames at 1280×720 are still pixel for pixel the accepted ones, and all 44 measurements still read by their bodies, with the same numbers. None passes only by its tell. The tells measured there, with the M3 games:
| Tell | At 1280×720 | At 640×360 | Median ΔE from the ring |
|---|---|---|---|
| The sentry's lens in view (walking, alert, its part hit, on the coast) | 7 to 31 px | 2 to 8 px | 18.2 (at rest, by day, on lit turf) to 66.4 |
| The sentry's lens turned away, or dead (the dodge, dead; its tell at 720) | 0 | 0 | — |
| The wanderer's lamp glass at night | 41 to 57 px | 10 to 14 px | 29.6 to 46.2 |
| Tideline at L3: the player's lamp glass | 3 to 8 px | 31.4 to 52.2 | |
| Tideline at L3: the sentry's lens (noticed, its tell) | 1 to 2 px | 50.3 to 86.9 | |
Tideline at L3: lens and lit vent (noticed, its tell; round) |
16 px; 13 px | 38.1 to 85.5; 30.5 | |
| Nightcoast at L3: the lens by day, at night, at night by the lamp | 3 px | 32.1, 52.7, 87.2 |
Every lit tell at night measures ΔE 29.6 or more. The lowest of all is a lens at rest by day on
lit turf (18.2), and its body is what reads there. The gate, ΔE 20, sits between them. The tell
needs 6 px at 720 rows (2 at 360). Lenses in view measure 7 to 31 px at 720 and 2 to 8 at 360;
Tideline's lens alone at its tell is 1 px at 360, under the floor, and its body reads there. The
planted invisible_enemy (a rock mimic, nothing lit) has no tell, and its check says exactly
what it said before: ΔE 4.9, red.
Text calibration
The text gates (decision 9) were checked on the frames people accepted before they gated
anything, with the engine reporting each text as it drew it: the T5 frames
(docs/images/m1/t5-*.png, staged again as engine/tests/kits/ui/gw_ui_shot.gd stages them),
both kit.actors proof tours and Tideline's proof tour shot again by the player at 1280×720 (the
13 t6b frames and Tideline's title came out pixel for pixel the accepted t6b-*.png and
tideline-title.png, so the label's refactor draws what it drew), and every shipped game's L3
frames at 640×360:
| Frames | Size | Texts | Smallest body height, px (scaled to 1080 rows) | Not whole or off the frame | Under 18 at 1080 |
|---|---|---|---|---|---|
| T5 title, HUD, carry, make | 1920×1080 | 66 | 18 (18) | 0 | 0 |
| t6b proof tours (13) | 1280×720 | 13 | 18 (27) | 0 | 0 |
| Tideline proof tour (13) | 1280×720 | 49 | 18 (27) | 0 | 0 |
| Tideline L3 (13) | 640×360 | 49 | 9 (27) | 0 | 0 |
| Burrow Run L3 (10) | 640×360 | 30 | 18 (54) | 0 | 0 |
| Pier Pressure L3 (4) | 640×360 | 16 | 18 (54) | 0 | 0 |
The smallest accepted text, T5's HUD at 1920×1080 (pitch 2, no pitchHeight), measures exactly
XAG 101's PC minimum of 18 px; nothing accepted is under it, so it is a gate. XAG's console
minimum (26 at 1080) would have failed 65 of the 66 T5 texts, so it is not the gate. No accepted
frame has text cut, short of lines or off the frame. The planted clipped_text (the
action_survival HUD's message line, 360 pitches wide, with a 109-character opening line) is cut after
"…keeps the sentrie" at 640×360 and is the only red text.
Text contrast
text_contrast (frames.ts, MIN_TEXT_CONTRAST) is red for a text whose ink doesn't stand out
from what is behind it. The engine reports where each text's ink lies; the core takes the pixels
of that rectangle, which hold the glyphs and the ground between their strokes, and measures the
WCAG luminance ratio between their light and dark ends (the 95th and 5th percentiles,
inkContrast). A rim or shadow counts as ground, as it does for a reader: kit.ui's HUD over the
world is rimmed, which is why it reads on any beach. The gate is WCAG's 3:1 for large text. It
came from the M4 Director run: the Director restyled Lamplighter's sky and had to work out the
HUD's contrast by hand, and a grey turn count on an orange sky stood about 1.0:1. Every accepted
L3 frame of the five sample games, measured before the check gated anything:
| Game | Frames | Texts | Faintest text drawn whole | Strongest |
|---|---|---|---|---|
| Tideline | 13 | 49 | 5.4 (ui.hud hand "IN HAND") |
17.6 |
| Nightcoast | 6 | 17 | 5.4 (ui.hud hand "IN HAND") |
11.6 |
| Burrow Run | 10 | 31 | 8.9 (ui.hud message) |
16.3 |
| Fog Signal | 11 | 47 | 8.1 (ui.title choose) |
16.5 |
| Pier Pressure | 4 | 16 | 7.0 (ui.hud turn) |
16.0 |
One text was under 3:1: Burrow Run's "Meadow done. Down into the caves!" at 1.4, caught by a shot
as the message line stepped down from its ink toward gone. A message line on its way out is
leaving, not meant to be read, so the label reports fading: true past its ink (70% of its
life) and the check skips it; the same line in its ink measured 8.9. The planted faint_text
(the grid template's HUD, its rim taken away, under a sky that starts in the HUD's own grey) is
red with the turn count at 1.1:1 and the level name at 2.8:1, and green with the template's
dark rim back: the rim is ground, so rimmed text reads on any sky.
Text on a phone
phone_text_fits and phone_text_size (decision 9, phone-text.md §7) take their floor and
rules from the web proof's phone visit, which was calibrated first (phone-text.md §2, §6): 9.9
CSS pixels of body, Apple's 11 pt. Every L3 frame of the seven gallery games, drawn again for a
phone held upright (2026-10-02, before the checks gated anything): 46 phone frames, every text
whole, inside the frame, clear of the others and of every pane, the smallest 11.0 CSS pixels
(Lamplighter's header 11.2), all green; drawn sideways too, also all green (the smallest 11.0 to
14.8). The planted phone_label is the only red: its goal line, whole on the monitor, cut on the
phone, where the web proof's phone visit cuts it too.
Text over text on a monitor
text_fits judges over and under on a monitor's frame by the phone's rules (decision 9). Every
gallery game's L3, on copies, with the check in place (2026-10-02):
| Game (L3's tours) | Monitor frames | Texts | Over or under | Phone frames | L3 |
|---|---|---|---|---|---|
| Tideline (proof) | 13 | 49 | 0 | 13 | green |
| Thornwick (look) | 16 | 67 | 0 | 16 | green |
| Fog Signal (look, resume) | 11 | 47 | 0 | 11 | green |
| Nightcoast (look) | 6 | 17 | 0 | 6 | green |
| Burrow Run (look) | 10 | 31 | 0 | 10 | green |
| Pier Pressure (look) | 4 | 16 | 0 | 4 | green |
| Acorn Rooftops (start) | 1 | 2 | 0 | 1 | green |
| Lamplighter (start) | 1 | 2 | 0 | 1 | green |
No Style, Screen or check was changed for it. The frames were read: the busiest (Thornwick's choice, a list over the talk's panes; Fog Signal's and Pier Pressure's titles, rows of hints) lay every text clear, and a text inside a pane is not under it (its own frame and the frames it sits in are drawn before it).
The planted crossed_labels is the only red: its tide line over its goal line on the monitor.
Judge calibration
The judge's margin and gate were set on frames people accepted, shot by the player at L3's
640×360 with every subject measured, and asked through the CLI backend (claude-opus-5-5,
effort low) on 2026-09-30: Tideline's two kit.actors proof tours (the 13 frames of
docs/images/m1/t6b-*), Burrow Run's look tour (7 frames, its gallery poster
meadow-over-spikes among them) and Pier Pressure's (3 frames, its poster among them).
| Kind | Asked | Inside the measured bounds | Within 12 px | Farthest miss |
|---|---|---|---|---|
| player | 23 | 23 | 23 | 0 px |
| threat (the sentry: walking, alert, its tell, a dodge, hit, dead) | 9 | 9 | 9 | 0 px |
Every answer landed inside its subject's pixels, so the margin (12 px at 360) changes nothing on
accepted frames and only forgives a point on a small subject's edge; the gate (the player in 75%
of frames) lets one miss in four through, since an answer is not a measurement. The planted
invisible_enemy (the mimic at ΔE 4.9) is the other side: the judge found the player
(0.500, 0.460, a hit) and said the sentry could not be seen, adding as a glitch note that a
shadow near (0.70, 0.78) had nothing drawn to cast it: most likely the mimic's own shadow,
beside its measured bounds. The comprehension pair read all four openings sensibly ("push the orange-red
crates onto the yellow target tiles, Sokoban-style"; "run right and jump over the spike pit …
get to the red flag") and agreed with both descriptions it had; with no game that should
disagree to try it on, it stays advice. Calibrating took 23 point questions (32 answers) and 6
text ones, $0.75 (Pier Pressure's came from the cache of its own L4 run).
M3b, after M3a lands
- L4 judge, grounded only. "Point to the player / the goal / the nearest threat" on a frame,
scored against the engine's projected positions; "what is the player supposed to do" on the first
minute's frames, compared with the brief's loop. Pairwise style checks between variants. Never a
free-form score, and glitch flags are advice for the person, never a failure. Backends: the
Anthropic API when
ANTHROPIC_API_KEYis set; otherwise the local Claude Code CLI (claude -pwith image files) for development; with neither, L4 reportsskipped: no judge configured, which is not green. As built (--level L4, core/src/verify/judge.ts and judges.ts):- Scored against measured positions, not projected ones: L3's readable check already finds each subject's pixels (the frame drawn again without it), so its bounding box is the truth. A hit is within 12 px of it at L3's 640×360. A projected point would be a guess at where a sprite's pixels fall; the bounds are where they are. Flat views had to learn to leave a subject out for this (decision 7's note).
- One question per frame names its subjects by archetype (
the character the player controls ("Hero"),a threat to the player, an enemy ("sentry"),the goal the player is to reach), never where they are, and asks for each one's middle as fractions of the frame, or found false rather than a guess, plus aglitchnote. A subject's kind comes from its archetype: the manifest's player; a threat is tagged enemy, hostile or threat, or has a melee component; a goal is tagged goal or exit; anything else is not asked about. - L4 runs on L3's frames even when L3 is red (an exception to the ladder's rule): the frame L3 called unreadable is the one whose answers a person most wants. L5 still needs every rung below it green.
- The gate is the player only, found in at least 75% of the frames that show it; threats,
goals, glitch notes and the comprehension pair are signals. The comprehension pair is the
opening's first three frames ("what is the player supposed to do?"), then a text-only yes
or no against the brief's pitch or the manifest's
meta.description. Pairwise style checks wait for variants to compare. - Backends: the Anthropic API through the SDK (
claude-opus-5-5by default,GAMEWRIGHT_JUDGE_MODELto change it; effort low, the schema as structured output, and the server-side fallback on a refusal), orclaude -pwith its system prompt replaced, only the Read tool, the frames' folders added,--json-schemaand--max-budget-usd. Answers are cached in.gamewright/verify/judge/by the frames' bytes, the question and the model: a re-run costs nothing.--judge-budget(default $1) stops asking once it is spent; a run that could not ask about the player in every frame is red. The Director's playtest tool does not offer L4 or L5.
- L5 web in the ladder: the golden path in headless Chromium through tools/web.sh, its state
hash compared with native. M5 runs the same harness against the live play origin.
As built (
--level L5, core/src/verify/web.ts): every Playtest tour, not only the golden path, runs throughtools/web/harness.mjs(tools/web.sh writes to fixed folders, so two runs would trample each other) on a play origin assembled per run, and natively throughsimwith the same tour, bundle, seed and ticks; green when the harness passes (no console error, failed request, blank shot) and both end on the same hash. Research §6 said compare outcomes, not hashes; every game so far ends on the same hash in both (the toy, Burrow Run's routes at tick 477, Pier Pressure's three tours), so the hash is the gate and a game that parts is a finding. The ladder never exports: build/player/web is shared with tools/web.sh, the play origin's deploy and other sessions, and an export takes minutes. When it is missing, or its build.json is not clean from a commit whose engine/ is HEAD's with engine/ clean now, L5 isskipped: no current web export …, never green, with the command.--level L5runs L0–L3 and L5; L4 runs only when named (--level L4,L5), since it costs money. - Proposals re-run the acceptance scenarios (D3 does this); M3 adds fuzz and the explorer to
the set, with budgets small enough for a proposal (seconds, not minutes) and full budgets on
gamewright verify.
Not in M3
RL agents (research §4: hours per game, rejected as a gate), VLM agents playing from pixels (rejected: 0.5–1.6% completion in VideoGameBench), and frame-time gates on this machine (llvmpipe timings don't count; CPU-side counters do, and are reported, not gated).