BUILDLOG — the 20-genre Build-Measure-Learn campaign
hayao.js v0.1 proved its thesis at N=1 (Sokoban — the friendliest possible genre). This campaign stress-tests the engine across the 20 most popular 2D indie genres, one game per genre, each a small blend of beloved indie titles. Every build must challenge genre-typical engineering (perf, collision, pathfinding, lighting…) and genre-typical design (game feel, pacing, readability, addictiveness). The engine is upgraded after every game; lessons land here and in LESSONS.md.
The loop (per game)
- Spec — genre, title blend, 3–5 stress goals (engineering + design), and the fun target: what must feel good for a human.
- Build — engine gaps first (new
src/modules, tested), then the game underexamples/<slug>/per CONVENTIONS.md. - Measure —
npm run check+npm test+npm run verifygreen; a per-exampleverify.tsproving content (winnability / critical path / balance sim / perf budget — whatever the genre’s truth is); a headless screenshot for looks. - Learn — a BUILDLOG entry below: what the engine lacked, what was upgraded, what convention changed, what transfers to other genres. Docs updated the moment a lesson lands (docs are prompts).
Roster
| # | Genre | Blend inspiration | Primary stress | Status |
|---|---|---|---|---|
| 1 | Grid puzzle | Sokoban | solver proof, determinism | ✅ v0.1 |
| 2 | Precision platformer | Celeste × TowerFall | tilemap AABB collision; full platforming kit (coyote, buffer, peak gravity, corner correction, dash, lift momentum) | ✅ |
| 3 | Metroidvania | Hollow Knight-lite | world graph, ability gating, reachability proof | ✅ |
| 4 | Top-down action-adventure | Zelda × Hyper Light Drifter | combat feel/juice kit, rooms, enemy AI | ✅ |
| 5 | Stealth | Mark of the Ninja × Gunpoint | vision cones, noise propagation, guard FSM | ✅ |
| 6 | Twin-stick horde | Nuclear Throne × Vampire Survivors | 100s of entities, spatial hash, upgrade economy | ✅ |
| 7 | Bullet hell | Touhou × Jamestown | 1000+ bullets perf ceiling, pattern DSL | ✅ |
| 8 | Tower defense | Kingdom Rush × Bloons | path following, wave balance sim, counter system | ✅ |
| 9 | RTS-lite | (mass units) | flow fields, 300–500 units, counters, HUD density | ✅ |
| 10 | Traditional roguelike | Brogue × Shattered Pixel | procgen + connectivity proof, FOV, turn scheduler | ✅ |
| 11 | Roguelike deckbuilder | Slay the Spire-lite | card DSL, balance bot, addictive loop | ✅ |
| 12 | Turn-based tactics | Into the Breach-lite | telegraphed intents, push chains, fairness proof | ✅ |
| 13 | Match-3 | Puzzle Quest-ish | cascade choreography vs deterministic sim | ✅ |
| 14 | Incremental/idle | Universal Paperclips-lite | big numbers, offline time, pacing curves | ✅ |
| 15 | Farming/life sim | Stardew-lite | calendar clock, save/load, gentle pacing | ✅ |
| 16 | Survival horror | Darkwood-lite | shadowcast lighting, spatial audio, dread pacing | ✅ |
| 17 | City/colony builder | Islanders × Mini Motorways | placement scoring, growth sim, minimal viz | ✅ |
| 18 | Rhythm | NecroDancer-lite | audio clock vs sim clock determinism | ✅ |
| 19 | Physics arcade | Breakout roguelite / Peggle | continuous collision, deterministic FP physics | ✅ |
| 20 | Top-down racing | Micro Machines-lite | car handling feel, racing-line AI | ✅ |
| 21 | Narrative decisions | Reigns-lite | content DSL, long-arc balance sim | ✅ |
| 22 | Physics demolition | Angry Birds × Crush the Castle | RIGID-BODY DYNAMICS: stacks, joints, CCD, sleep, contact events | ✅ |
| 23 | Pinball | brass-parlor table | kinematic motor paddles, extreme-speed CCD in tight geometry, no-dead-pocket proof | ✅ |
| 24 | Slide-and-merge puzzle | 2048 × stone blocks | reduce an endless-arcade genre to a solver-provable finite puzzle; merge-run resolution across partitions | ✅ |
Order of battle: waves grouped by shared engine needs — movement/collision (2–5), mass/perf (6–9), turn/UI (10–14), atmosphere/sim (15–21). Order may be re-shuffled as lessons emerge.
Entries
24 · Emberfold — slide-and-merge puzzle (2048 × stone blocks) ✅
The gap it closes. 2048 is one of the most-cloned 2D games there is, but it
resists this repo’s core claim: endless random-spawn arcade play can’t be
machine-proven winnable. Emberfold takes the merge, drops the parts that fight
proof, and adds a twist that puts them back as difficulty: a finite, dealt board
with no spawns — slide the whole grid, equal embers fuse to their double — plus
immovable stone blocks that partition every row and column into independent
merge-runs. The result is a pure Puzzle<Grid, Move> the BFS solver bites on
exactly like Sokoban: 40 boards, every one proven fusable to its target, the
minimum-slide par proven honest, the curve proven to ramp 2→11.
No engine gap — it’s a reduction. Zero src/ changes. The whole game rides
existing primitives: composeCampaign + generateLevels deal candidate boards
(stones, then a 4, then the 2s) from a seeded Rng and keep only the in-band,
solver-proven ones; the campaign ships as LevelRecord[] (seed + deal recipe +
proven depth), re-derived identically everywhere and asserted equal to a fresh
compose so it can’t drift. Art is code-as-art: embers are warm gradient tiles that
glow hotter as they climb the heat ramp; stones are cold slate; the forge breathes a
radial glow — all cosmetic, so only the dealt grid enters world.hash(). Covers all
six proof channels (100% eval coverage).
What the probe taught. The solver counts slides, not merges — and one slide can cascade many fusions, so depths sit LOW (2–11) and cluster tight. Two knobs actually move difficulty: material (embers·2 + fours·4 must exceed the target with slack, or the board is unsolvable — an early A4 config dealt heat 28 for a target of 32 and every candidate was correctly rejected) and stones + board size (partitions force feeding runs in the right order → deeper search). Lesson, same as Lanternfold’s: set generator bands from the measured depth distribution, and let the generator loudly fail an impossible band rather than ship a thin act.
What transfers. The move that made it provable is the general one: to fit an “endless” or randomness-driven arcade genre into the solver’s frame, strip the nondeterminism and re-add the challenge as spatial structure. No-spawn 2048 is to 2048 what a Sokoban level is to a warehouse — the same verb, made finite and fair. The stone-as-partition trick (resolve each maximal non-stone run independently, then stitch) is a clean pattern for any grid-slide mechanic that wants obstacles.
B2 · Gravewell — benchmark reproduction of Black Hole Square (js13k 2021, #9) ✅
Second rung of the benchmark ladder. Target: a 6×6 tap-puzzle — X squares vanish, neutron stars collapse into black holes, arrows slide contiguous runs of debris, holes swallow whatever slides in; every level has a hard tap budget (par).
Shipped: pure tap rules (exact extraction from the original’s
shiftPieces, including the subtlety that blocked pushes don’t consume
budget), 5 original levels teaching tap → collapse → sweep → gap-cost →
collapse-then-sweep combo finale, keyboard-native tap model (cursor +
confirm; cursor position is canonical state), three distinct fail states
surfaced (out of taps / not clean / stuck), piece-taxonomy view with on-screen
legend. Verified: every level solver-proven within budget (3→3→3→5→6, finale
deepest) AND proven UNsolvable in par−1; full campaign replayed through the
cursor model with 0 taps to spare on every level; deterministic + golden;
finale feel probes (monotone cleanup, ≥1 positioning tap, last tap lands at
0); filmstrip + taxonomy still judged readable.
Fidelity score (rubric in the retired BENCHMARK doc): mechanics 10/10 M-checks green · content parity: teaching arc ✓, volume 5 vs original’s ~40 puzzles (arc ✓, volume ✗) · feel/look ✓ · learning yield: no engine gap, two findings below.
Findings:
- Pointer-designed games reproduce keyboard-first with a cursor+confirm
model, and gain determinism for free. Every “tap” is an action in the
same replayable input log as any key;
tapsToFrames()compiles solver paths (cell indices) into cursor walks. The cursor is CANONICAL state (it decides what confirm does) — the temptation to mark it cosmetic would have broken replay silently. - Move budgets are negative proofs, refined. Setting par := solver optimum makes “par−1 unsolvable” true by construction — but asserting it in CI is what protects the budget from later content edits (nudge one piece and the assertion, not a playtester, catches the slack). B1’s load-bearing-mechanic lesson, applied to budgets.
- The blocked-push-is-free rule is the design’s hidden generosity. The
original only charges taps that change the board (
nChanges > 0). Missing that detail would have made every level rating wrong — rules extraction from source (not from play) caught it.
CO-OP · Kinfall — 2-player local co-op survival (surviv.io duo) ✅
The engine already had player-namespaced input (p1:* / p2:*, from
Fernclash) and the horde stack (SpatialHash broad phase + quadratic spawn ramp,
from Emberwake). Kinfall composes them into the first cooperative shared-world
game: two players on ONE keyboard drive ONE world.state.kin, surviving a
closing storm ring together. Controls are exactly as briefed — P1 on WASD with
«,»/«y», P2 on the arrows with «.»/«-» — mapped straight into the hot-seat input
map, zero engine changes.
Shipped: a 100-second night on a walled field; a stormcircle that shrinks through keyframed holds and bleeds anyone caught outside (the surviv gas); six loot crates that raise a player’s weapon tier (pistol → SMG → scatter → rifle); an auto-aiming gun so 8-way movement can still fight; a quadratic horde of swarmers + brutes chasing the nearest STANDING player; and the co-op soul — a down / bleed-out / hold-to-revive loop. Extraction (win) needs one survivor at t=100; a full team wipe (both dead) loses. Verified: the co-op pair-bot holds the ring to extraction on 6/6 seeds while an idle pair is wiped by ~39s (the skill delta); the storm proven load-bearing (a rim-camper is knocked out in 1.5s — no safe camp); revive proven BOTH ways (a partner in range hauls you to 5 hp; unattended you bleed out and die); deterministic + golden; feel probes (first kill after the loot grace, storm monotonic, kill-lull bounded, final ring peaks ≥ 40 enemies); layout + control-hint lints clean; salience feel-gate green.
Findings:
- Co-op is a shared-state generalisation of hot-seat, not new engine.
Fernclash namespaced input for a duel (two bodies, opposed); Kinfall reuses
the identical
playerInput('p1'|'p2')seam for cooperation (two bodies, one sharedworld.state.kin, one shared horde). The fixed p1-then-p2 iteration order is all determinism needs — the whole verify harness (golden, snapshot, timeline) worked on the two-player shared world unchanged. - A staged loot economy needs a spawn-grace window or it self-strangles. The first cut spawned enemies from frame 0, so the “stand 0.7s to open a crate” loot action was a death trap — nobody armed up, everyone bled out by ~45s. Adding an 8-second calm opening (surviv’s early loot phase) let the pair arm before contact and flipped 0/6 losses to 6/6 wins. The genre truth: a loot-then-fight loop must guarantee a safe loot window, and that window is a tunable, not a vibe.
- Reachability arithmetic applies to PICKUPS, not just platforms. Four of six crates were placed on top of the cover blocks (unreachable), so only the two center crates ever opened and exactly one player armed per run. Same class as Shard Ascent’s frame-perfect gap — “it looks reachable” is not a proof; crate positions must be checked against the actual wall geometry.
- The revive loop is best proven in isolation, like a tactics mechanic. A well-tuned bot only gets downed at the buzzer, so the winning run rarely exercises a real revive — hoping the pilot triggers it is flaky. Instead the suite constructs the downed state directly and proves the revive both ways (helped → up; alone → dead), the Vantage pattern (push/bump proven in isolation) applied to co-op grace.
NET · Fernclash — deterministic multiplayer (lockstep + rollback netcode) ✅
The engine was always a lockstep core (step(inputs) pure, seeded rng,
hash()); this build added the missing transport and proved it with a
2-player game. New src/net/: player-namespaced inputs (p1:left — merged
frames are ordinary frames, the whole verify harness works on multiplayer
unchanged), bus transports (BroadcastChannel tabs / zero-dep WebSocket relay
in scripts/relay.mjs / deterministic LoopbackHub with latency+loss for
tests), LockstepSession (input delay, redundancy windows, stall-not-desync),
RollbackSession (snapshot-ring predict/rollback on the proven
snapshot()/restore()), room layer (seed+roster handshake, mid-game join via
snapshot shipped at an agreed future frame, leave cutoffs), periodic
hash() exchange that freezes on desync and dumps a replayable input log.
Fernclash (examples/fernclash/): sumo duel on a fern ring — the same
game runs hot-seat (pre-namespaced input map) and true netplay with zero
branches in game code. Verified: bot duel won 3–0; golden replay;
deterministic; two lockstep peers finish a full match over a lossy, laggy
loopback and agree bit-for-bit; real-socket relay covered in
src/net/relay.test.ts.
Findings:
- Cross-machine determinism is stronger than same-machine determinism.
Math.sin/cos/atan2/pow/hypot/logare implementation-defined per JS engine. Newcore/dmath.ts(fdlibm-style, exactly-rounded ops only) replaced every sim-side call, andnpm run invariantsnow bans the raw forms. Goldens moved once (sub-ulp shifts) and are pinned to the deterministic forms. - Registered custom nodes are the netplay-safe pattern. Logic in a
registerNode’d class survivesrestore(); closure behaviors don’t. The fernclash rule: rematch flows through the input stream (againaction), never a DOM callback that mutates state on one peer only. - A late joiner must not bootstrap-zero the veterans’ inputs. Only genuinely-new players get empty bootstrap frames; the welcome also carries still-pending joins. Caught by the room-level late-join test as a desync at the first hash interval — exactly the failure mode the hash exchange exists to catch.
- Full doc: NETWORKING.md.
B1 · Seamfold — benchmark reproduction of Edge Not Found (js13k 2020, #2) ✅
First rung of the benchmark ladder: reproduce a human-ranked
game under the house discipline. Target: Sokoban on a twisted torus — no
outside, seams that shift you along the other axis (xOff/yOff).
Shipped: pure twisted-torus rules (fixpoint wrap resolution — the original ships a commented known bug in its x→y→x ordering), 4 original levels teaching torus → yOff twist → xOff twist → both, 3×3 ghost-copy tiling honoring the twist (all cosmetic), undo/restart, win/next-level loop. Verified: every level solver-proven winnable (8→6→5→13 moves, finale deepest) AND proven UNsolvable in a no-wrap variant — the seam is machine-proven load-bearing; full campaign replayed through the scene view; deterministic + golden; timeline probes show exactly one visible box seam-jump in the level-1 solve; filmstrip + stills judged readable.
Fidelity score (rubric in the retired BENCHMARK doc): mechanics 8/8 M-checks green · content parity: teaching arc matches, volume 4 vs the original’s 20+ levels (arc ✓, volume ✗) · feel/look ✓ (3 timeline metrics + judged artifacts) · learning yield: no engine gap (see below) + one new lesson + one friction fix.
Findings:
- The negative solver proof is the benchmark’s best fidelity instrument. “Winnable” is table stakes; asserting the mechanic is load-bearing — same rules with seam-crossings forbidden, solver proves UNsolvable — turns “this level teaches the twist” from intent into CI. Generalizes to any mechanic-gated genre (see LESSONS).
- The solver out-designed the designer twice on one level. The finale hand-trace said “no-wrap solvable” (M4 violation); the solver proved the goal-placed boxes seal the player out of both push regions, so it’s genuinely seam-or-nothing — a property designed by accident, discovered by proof. In-head verification failed in BOTH directions on a 6×6 grid.
- Reproduction cost at rung 1 was ~zero engine work. Grid puzzle is the
engine’s home turf; the alien state model (quotient space instead of
bounded grid) fit
Puzzle<State, Move>untouched. The ladder’s later rungs (real-time feel, juice/scale) are where gaps should surface. - Spec-card extraction from ranked source works. Reading the original’s
wrapCoordsgave exact semantics (including its bug) in minutes; the spec card’s M-checklist mapped 1:1 onto verify checks. Design extracted, zero code ported.
Playtest wave 1 — the unmeasured layer (first human contact)
The first human playtest reported five defects; every one instantly read as “no human reviewed this”, and every one shares a root cause: it lived in the layer the verification philosophy exempted from measurement. “The cosmetic layer can be deleted without changing the game” had been treated as license to never verify it. Bots read probes, not pixels; they know the controls a priori and what every entity is — so buried HUDs, kissing labels, bracket soup, missing onboarding, indistinguishable pickups, unearned story payoffs, and edge-hiding cheese were invisible BY CONSTRUCTION.
Reported → root cause → systemic fix:
- Shard Ascent: HUD under the tiles. Text z defaulted to 0 under z2
tiles; nothing ever asserted paint order. →
verify/layout.ts: the display list is pure data, so text readability is now linted (panel-or-disjoint rule, scrim-aware); the HUD got a scrim, and the lint is a verify stage. - Thornspire: text kissing the boss; illegible bracket-soup hand. Layout was absolute-positioned prose with no layout contract, information design never a target. → explicit layout contract in the view (the circle owns its band; one card per line, left-aligned, “press N · Name — effect” wording, energy pips), linted on BOTH screens.
- Vantage: no way to learn the controls. Bots emit actions directly —
onboarding is invisible to them. → onboarding overlay + context-sensitive
coach line, and
missingControlHints(): every mapped action must be named on screen (frame-1), now portfolio convention. - Duskveil: ship hides at the rim + “The Duskveil lifts” pays off nothing.
(a) The Canvas backend STRETCHED (100%/100%, no aspect preservation — SVG
letterboxed, canvas didn’t; headless never renders through CSS, so nobody
saw it) and the clamp ignored sprite extents. → canvas
object-fit: contain; clamps include extents; a no-safe-camp probe (parked at the rim → hit in 9.6s). (b) Ending copy introduced a noun the game never showed. → the boss is NAMED in the HUD (asserted), and the fiction rule enters CONVENTIONS: endings may not introduce new proper nouns. - Hollowdeep: hp/atk unglyph’d, pickups look like monsters. State knows what everything is; the screen didn’t say. → flask-shaped potions, hostile outlines on creatures, a true-glyph legend strip, ♥/⚔/⚗ HUD — and the legend is part of the linted screen.
The meta-lesson joins the synthesis laws as #8: verify the human-contact layer with the same machinery — the display list is data, onboarding is a checkable contract, and “would a stranger understand this screen in 30 seconds” is a test, not a vibe. Remaining human-only judgement (taste, tone) routes through the shots/ artifacts: emitting key screens per verify run makes “a human looked at it” a pipeline step instead of an accident.
Campaign synthesis (all 20 genres complete)
The portfolio’s cross-genre laws, earned the hard way:
- Every genre has a mechanical truth, and it is provable. Puzzles have solvers; movement has bots; economies have pacing windows; counter systems have duels; procgen has connectivity; stealth has both-ways affordance proofs; rhythm has frame-exact windows; narrative has content lints. The verify suite IS the design document.
- Skill-delta proofs are the closest thing to a fun proof: drafting beats skipping (17/20 vs 9/20), greedy beats random (158 vs 82), braking beats flooring (26.2s vs 27.7s), judgement beats recklessness (19/20 vs 0/20), counters beat spam. If a null strategy competes with intended play, the game is broken — assert the delta.
- Derive constraints, don’t vibe them: movement envelopes before levels, season length ≥ growDays, turn radius vs corner radius, fuel arithmetic vs night length, spike damage vs block ceilings. Every “feels wrong” traced to a violated inequality.
- Null-strategy baselines are the cheapest scenario test: the undefended lane, the do-nothing tactics turn, the never-draft climb, the camping keeper. A threat that a null strategy survives isn’t a threat.
- Grace is a system, not polish: coyote/buffers, i-frames, hit-stop input buffering, wound-before-death, mercy clears, phase-transition clears — the same shape at every timescale, and each is unit-testable.
- The observer split held everywhere: cosmetic views (pooled sprites, spring choreography, particle bursts, spatial audio) were deleted-without- diff throughout; the instant-sim/animated-view split (Glimmerfall) is its purest form. The beat being sim time (Cadence) is its deepest consequence.
- Pure-data state pays compound interest: structuredClone-and-score powered tactics options, Peggle aim search, and deckbuilder pilots; hashing and goldens pinned 20 games; every sim doubles as its own planning model.
2 · Shard Ascent — precision platformer (Celeste × TowerFall) ✅
Shipped: 6 teaching-ramp levels (run/jump → coyote gaps → drop-through + shard fetch → apex dash → spike rhythm → moving-lift finale). Every level bot-proven beatable with 0 deaths; full-run determinism verified; spike lethality verified; 6.4 kB gzipped. Completion telemetry: 3.1s / 3.0s / 5.2s / 2.8s / 3.1s / 8.0s — the ramp shape falls out of the verify labels for free.
Game phase findings:
-
Reachability arithmetic must precede level authoring. Bolt’s chasm was 8 tiles; the movement envelope (jump distance + apex-dash bonus) computes to ~263px — the gap was 256px from the takeoff edge: frame-perfect, i.e. broken for humans. “It looks dashable” is the platformer equivalent of the Sokoban unwinnable-level trap. Upgrade:
jumpHeight/jumpAirtime/jumpDistance/ dashJumpDistance(cfg)are now engine API — derive geometry from the config. -
Grace mechanics must persist across the state they change. Drop-through required
onGround, which the drop itself falsifies after one frame — so the body re-landed on the platform lip forever. The class of bug: a conditional input window keyed on state that the triggered action immediately invalidates (coyote/buffers have the same shape). Frame traces catch it; playtests just say “down doesn’t work”. -
Reactive waypoint bots beat open-loop input scripts. Semantic plans (walk/jump/dashJump/drop/waitPlat/ride, ~5-8 steps per level) survived every physics and level tweak during iteration; recorded frame scripts would have invalidated on each change. The bot found real bugs scripts would mask: the run-up problem (jumping the frame you land = zero-speed launch) and the momentum drift below. Candidate for engine promotion after two more genres use it (rule of three).
-
Momentum realism cuts both ways. Low
airFriction(added for lift momentum) makes a straight drop drift ~70px sideways when entered at run speed. The bot missed the shard; a human would too and blame the controls. Design rule: place drop-targets wide, or expect walk-backs. -
Process: two builder subagents stalled repeatedly mid-stream before writing code; the inline build with a trace harness (probe printout every 30 frames) was fast and every failure was diagnosable from the trace alone — the headless-first architecture carried the whole debugging loop; a browser was never opened until the final looks check.
-
world.hash()had a hidden-state escape. Canonical state kept outside the scene tree (a character controller’s velocity/timers, pure-sim structs) was invisible to hashing and snapshots — determinism checks could pass while real state diverged. Fix:world.state, a plain-JSON bag included inhash()/snapshot()/restore(). Convention: pure-sim state lives there. -
Game-feel features interact; only frame-exact tests catch it. Lift momentum storage (jump inherits platform velocity) was silently destroyed by air friction three frames after takeoff. The unit test caught what a playtest would have reported as “jumps feel dead off lifts”. Split
airAccel(steering) from a deliberately gentleairFriction(no-input decay) — inherited momentum must survive flight. -
Collision probes matter as much as the mover. Corner correction (jump + dash) is just “try nudged positions and ask the geometry” — exposing
rectBlocked()made both corrections ~10 lines each. Movers without query functions force reimplementation. -
The platforming canon is provable in Node. Coyote time, jump buffering, variable height, halved-gravity apex, both corner corrections, wall slide/jump, lift momentum: 15 tests, each pumping fixed steps and asserting on state. Game feel as unit-testable transitions — the engine’s pitch holds for real-time movement, not just grid puzzles.
-
Analytic clamping beats substepping.
moveRectscans the tile span of the swept axis and clamps to the nearest obstacle edge — exact contact, no tunneling at any speed, no iteration count to tune.
3 · Sproutveil — metroidvania (Hollow Knight-lite) ✅
Shipped: four connected rooms (Atrium / Shaft / Roots / Crown), two ability pickups (double-jump seed, dash boots), spike hazards, ability-gated progression to the Heart. Bot-proven full run in 27.1s with 0 deaths; both gates proven REAL; snapshot save/load round-trips; deterministic.
Findings:
- Metroidvania gates need NEGATIVE proofs, and the movement envelope can’t give them. The envelope is a lower bound (apex gravity extends real jumps beyond it); “impassable” claims need an upper bound. Answer: simulate the best ungated maneuver against the real room geometry — a max-effort jump under ledge1 tops out 60px short; a DJ-only sprint dies in the Crown gap. Gate proofs are maneuver sims, not arithmetic.
- Engine bug:
snapshot()didn’t carry input state, buthash()includes it — restore left post-snapshot held keys in place and the hash mismatched. The fix is mechanical, but the class matters: every field inhash()MUST round-trip through snapshot/restore, or save/load fails determinism checks in ways that look like game bugs. (Second instance of “hash and snapshot must agree” afterworld.state— now both are engine-enforced.) - Tilemap OOB-is-solid conflicts with room exits. A body in a border opening is clamped at the boundary, so its center can never actually leave the room — transitions must trigger a few px INSIDE the border, and entry positions must land beyond the opposite threshold or rooms ping-pong.
- Bot lessons compound into a controller-usage manual. Four new failure modes, all genre-real: mount wide ledges with a near-vertical takeoff (momentum drifts you under the lip); a DJ tap needs a released frame first (no input edge otherwise) and must then be HELD (the variable-jump cut slashes an instant-release air jump to 40%); gap-cross jumps want full speed and constant steering — the exact opposite of ledge mounts; take off from the ledge EDGE for max-range flights. These are things human players learn in minutes and bots must encode — and they generalize: any AI-authored platformer level should be tuned against these maneuver templates.
- Third use of the waypoint bot incoming (rule of three) — promote a
generalized
verify/botto the engine during the next platformer-adjacent build (G4 top-down uses different steering, so likely G16/G20).
4 · Gleamvale — top-down action-adventure (Zelda × Hyper Light Drifter) ✅
Shipped: four rooms, sword-arc combat with hit-stop/knockback/i-frames, three telegraphed enemy types (chaser, darter, sentry), key-locked vault, heart-container win. Combat bot wins in 31.6s, 0 deaths, floor of 2/3 hearts; door gate proven both ways; enemies containment-checked every frame; deterministic.
Findings:
- The physics layer is genre-agnostic — zero engine changes needed.
moveRectwith no gravity IS a top-down mover; walls, pillars, and the door-as-SolidRect all came free. The platformer investment paid out here. - Hit-stop eats inputs unless you buffer through it. Freezing the sim for juice means an attack mashed during the freeze vanishes — a real feel bug the test caught (a slash that “randomly” didn’t come out). Rule: any pause the sim injects (hit-stop, screen transitions) must buffer intent across it. Same family as coyote/jump-buffering: grace for human timing error.
- Combat balance verifies differently from puzzles: no solver, but a kiting bot’s run telemetry (win time, hp floor, deaths) is a tight proxy — hp floor of 2 says “comfortable”; a floor of 0-1 would demand tuning. Telegraphs (darter’s 0.45s flash) are what make the bot — and humans — able to play reactively at all.
- Design bug caught by the bot: a pillar in the exit lane. Cover placed at the room’s centre column blocked the only path to the north door — a 10-second playtest find, but the bot found it headlessly, and the fix is asserted forever. Rule: keep exit lanes (door columns/rows) obstacle-free.
- Third bot confirms the pattern (probe → plan-interpreter → actions);
steering differs per genre but the skeleton is identical. Promotion to
verify/botscheduled for the next movement game.
5 · Veilstep — stealth (Mark of the Ninja × Gunpoint) ✅
Shipped: single-level heist across three patrol bands: vision cones with raycast LOS, a fill/drain detection meter, bush concealment, sprint noise that pulls guards to investigate, alarm-reset. Heist bot: idol stolen and exfiltrated in 33.3s, 0 alarms. All stealth affordances proven both ways (exposure punished in 1.3s; a bush inside a patrol lane conceals through a full loop; noise flips guards to investigate). Deterministic.
Findings:
- Engine additions: DDA grid raycast (
raycastTiles/lineOfSight/inVisionCone) and the promotedcreatePlanBot(the fourth bot rebuild became the engine’s plan-interpreter skeleton with pluggable step executors +steer2D). The raycast will be reused by roguelike FOV (G10) and horror lighting (G16). - Stealth verification is about proving the systems BOTH ways. A stealth game where hiding isn’t necessary, or hiding spots don’t work, is broken in ways that “the bot won” can’t see. The suite asserts the punishment (exposed = spotted fast) and the affordance (bush = never spotted) as first-class checks alongside the positive run.
- Level-design lesson: every long traversal needs a safe pocket at its midpoint. The first level cut had no cover near the far gap; the exfil required THREE patrol phases to align at once — a wait with ~2% per-cycle probability, i.e. unfair, discovered as bot timeouts. One bush under the arch made the route fair without weakening any individual guard. In stealth the difficulty knob is WAIT TIME, and joint-phase waits explode combinatorially — chain single-guard windows via safe pockets instead.
- Guard cone range (260px) is the real balance parameter: exfil windows work not because guards are far in absolute terms but because their cone can’t touch the corridor for the duration of a continuous move. Reasoning about “cone-shadow duration of a path”, not guard distance, is what made the final plan provable.
6 · Emberwake — twin-stick horde survival (Nuclear Throne × Vampire Survivors) ✅
Shipped: 120-second survival night: auto-aim fire, quadratic spawn ramp, two enemy types with soft-separation flocking, kill-driven level-ups with pick-1-of-3 builds (sim pauses on choice, picks are input actions). Orbit bot survives with hp floor 5/8, peak horde 161, 569 kills; sim step averages 0.02ms at peak (100× under the 2ms budget); deterministic.
Findings:
- Perf: the SpatialHash pattern holds effortlessly. Rebuild-per-step + queryCircle for bullets and separation runs the whole night at 0.02ms/step in Node — entity-count ceilings live in the RENDERER, not the sim. Hence the view lesson: pooled sprites (update-in-place, hide extras) and the Canvas2D backend; rebuilding hundreds of scene nodes per frame is an allocation storm the sim never sees.
- Kiting bots corner themselves; orbiting bots don’t. Flee-the-centroid drove the bot into walls every run (all deaths at x=1236). The genre’s real skill is orbiting the arena — once encoded, survival became a question of pure build/balance. Bot strategy IS design knowledge.
- Balance tuning was three sim runs: died-at-82s (ramp too hot) → untouched-with-19-alive (upgrades too strong for a linear ramp) → the keeper: quadratic spawn ramp vs multiplicative build growth. Horde genres want spawn pressure superlinear to stay ahead of exponential player DPS — and ‘peak alive ≥ 150’ is asserted so the horde FEEL can’t silently regress.
- rng-in-the-loop determinism works: spawns and upgrade offers draw from
world.rnginside the pure step (passed in, never imported) and the whole night replays hash-identical.
7 · Duskveil — bullet hell (Touhou × Jamestown) ✅
Shipped: three-phase boss with a declarative pattern DSL (ring / fan / rain, spin + arc + cadence params), 5px hitbox, focus mode, grazing, mercy-clears on death and phase transitions. Dodge bot clears the full fight deathless in 143.6s at 487 peak live bullets; sim rounds to 0.00ms/step; deterministic.
Findings:
- Pattern fairness has a mechanical proof: a greedy lookahead dodger. 9 candidate moves × 26 frames of linear bullet prediction is enough to survive everything a fair pattern throws; if that bot dies, humans die unfairly. This is the real-time analogue of the Sokoban solver — the strongest fairness gate in the campaign so far.
- Dodging is trivial; UPTIME is the game. The bot’s first version survived forever and never won — staying alive away from the boss is easy, the skill is holding fire lanes under a moving boss. Scoring had to weight ‘be under where the boss WILL be’ (leading its sway) above clearance saturation. Design mirror: bullet hells are positioning games disguised as dodging games; patterns should punish camping, not movement.
- The pattern DSL earns its keep: three phases = 8 data lines. Difficulty knobs (count, cadence, spin, arc) tune density independently of fairness — thickening phase 3 from 297 to 487 peak bullets cost the deathless bot nothing, confirming density ≠ difficulty when patterns stay coherent.
- Mercy rules are structural, not polish: the respawn bullet-clear radius and phase-transition clears are what keep 3 lives meaningful at this density — without them, deaths cascade. (Same family as i-frames.)
8 · Rootward — tower defense (Kingdom Rush × Bloons) ✅
Shipped: S-curve lane with waypoint interpolation, 12 build pads, three counter-typed towers (arrow / frost aura / splash cannon), ten composed waves with a bounty economy, cursor-driven building via input actions. The scripted mixed build survives 10/10 waves at 9 lives; an arrow-only build of a larger budget falls to the tank waves; the bare lane falls on wave 2; the pressure curve ramps with breathers; deterministic.
Findings:
- A counter system must be proven from BOTH sides. After the first range buff, arrow-spam beat everything — the “counter” was decorative. It became real only when tank arrow-resist dropped to 0.12 (arrows ~useless), which is the design rule: soft resists (45%) get erased by tower-count scaling; counters need to be near-hard to force build diversity.
- Tower coverage is geometry, not stats. A range-175 tower on a pad 120px off the lane covers a 254px chord — 3.2s of fire, less than one grunt kill. Pad placement × range defines the real DPS; the range ring in the HUD is the single most important piece of UI in the genre.
- A verify-driver bug masqueraded as a design failure: holding ‘next’ every frame edges justPressed once, so the cursor stuck and NOTHING was built — ‘mixed build’ and ‘undefended’ were identical runs. Symmetric to the shard-ascent DJ-tap lesson, from the driving side: edge-triggered inputs need explicit release frames in any scripted driver. Worth a helper in the engine bot at synthesis time.
- Wave curves aren’t monotone — they breathe. Runner waves are pressure breaks by design; the right telemetry gate is ‘each wave ≥ 55% of the previous, finale is the peak’, not monotone hp.
9 · Bramblefall — RTS-lite (mass units + counters + HUD) ✅
Shipped: keep-vs-keep skirmish: BFS flow-field pathfinding (cached per goal tile), 260+ units steering with hash separation, spear→cavalry→archer counter triangle, per-unit order targets, reinforcement trickle, a pulsing enemy commander, cursor-command HUD with per-type army counts. Verified: every counter edge wins its 40v40 duel to extinction; the commander bot (turtle → counterpush) razes the keep in 82s with 120 standing; a walled-off unit routes around brambles in 3.7s; 0.80ms/step at peak; deterministic.
Findings:
- Flow fields are the cheapest ‘real RTS tech’ win: one BFS per ordered goal tile (40×22 grid, cached as derived data OUTSIDE state) gives hundreds of units wall-aware pathing with zero per-unit search. The cache-not-state distinction matters: fields are recomputable projections of (map, goal), so hashing/serializing them would only bloat snapshots.
- Unit arrival tolerance is API, not detail. Units hold 50px off their goal tile; the verify assertion assumed 40px and ‘failed’ a working system. Wherever a sim has a behavioural tolerance, EXPORT it — tests and bots must share the sim’s own constants, not re-guess them.
- Strategy is the balance test in symmetric games. Attack-move-everything loses the keep race; turtle-then-counterpush wins with 120 spare — the matchup is defender-favoured (keep + massed army beats a marching column), which is correct for a defend-your-base design. The verify bot documents the intended line of play, exactly like Rootward’s build order.
- Perf headroom confirmed for the wave: 265 units full-combat at 0.80ms/step — same rebuild-per-step hash pattern as Emberwake, an order of magnitude of margin left for a bigger RTS.
10 · Hollowdeep — traditional roguelike (Brogue × Shattered Pixel) ✅
Shipped: three procgen floors (rooms + L-corridors), raycast FOV with lit/explored/unknown memory, turn scheduler (shades act every other turn), bump combat, potions + a blade upgrade, descent to the Pale Amulet. Verified: connectivity across 50 seeds × 3 floors (stairs + ALL loot reachable), seeded layouts reproduce exactly, a full-knowledge explorer bot wins seed 1 and 10/10 random seeds, turn log replays deterministically.
Findings:
- The classic procgen bug appeared on schedule and the classic proof caught it: one branch of the L-corridor carver skipped its vertical leg, so ~half the room links were walls. 162/150-floor connectivity failures — and the bot’s 3/10 win rate — from one wrong line. After the fix: 0 broken, 10/10 wins. Procgen without a reachability gate is unshippable; with one, the bug survived less than a minute.
- Fairness ≈ connectivity in this genre. The bot’s win-rate jumped from 3/10 to 10/10 purely from the topology fix — no combat rebalance needed. Assert connectivity FIRST; only tune numbers when topology is proven.
- Turn-based reuses the whole real-time stack: the engine raycast does FOV (per-tile lineOfSight with a small inset for wall lighting), input edges ARE turns (one justPressed = one world step, held keys don’t repeat), and the sokoban replay pattern covers a 900-turn run unchanged.
- Full-knowledge bots prove winnability, not player experience — the bot pathfinds with the whole map. That’s the right claim for procgen (‘a winning line exists’), the same epistemic status as the Sokoban solver.
11 · Thornspire — roguelike deckbuilder (Slay the Spire-lite) ✅
Shipped: data-driven card DSL (8 cards: dmg/hits/block/draw/vulnerable), 3-energy turns with reshuffling piles, deterministic enemy intent scripts (attack/block/charge-doubles-next), pick-1-of-3 drafts after every fight, an 8-node climb (fights, rests, an elite, the Spire Heart). Verified: a greedy pilot wins 17/20 seeds (target 11–19); a never-draft pilot wins 9/20 — progression proven; 30 turns of intents audited as exactly honest; seed-1 climb pinned as a golden replay hash.
Findings:
- Win-rate windows are the genre’s balance instrument, and both edges matter. First tuning: 3/20 (charged spikes exceeded any block ceiling — 32 incoming vs ~16 blockable is not tension, it’s a coin flip about draw order). Second: 20/20 (no tension at all). The keeper sits at 17/20 via spike damage ≤ pool+heal arithmetic. The assertion window (11–19) means BOTH failure directions break CI.
- ‘Drafting matters’ is provable: the same pilot with drafting off drops from 17 to 9 wins. That delta IS the genre — if skipping every reward were competitive, the deckbuilder would be a solitaire timer. Assert the delta, not just the win rate.
- Intent honesty is a one-line audit worth its weight: resolve each telegraph and compare actual hp loss to the shown number, block included. Any future charge/vuln/block interaction bug fails loudly here — this is the perfect-information contract Into the Breach lives on (G12 next).
- Turn-based + rng-in-sim (shuffles) replays fine: the draw pile is state, the shuffle draws from world.rng, and the golden hash pins the whole run.
12 · Vantage — turn-based tactics (Into the Breach-lite) ✅
Shipped: 8×8 grid, three mechs (melee push / lobbing artillery / ranger), bugs with directional telegraphs resolved exactly as shown, push mechanics with bump damage and chain redirects, greenhouse protection over five turns, scripted spawns. Verified: a 1-ply greedy defender achieves a PERFECT defence; push-redirect, rim-bump and unit-bump each proven in isolation; a do-nothing defence loses everything (threat real); golden end-state.
Findings:
- The do-nothing baseline is the cheapest scenario-design test in the campaign. First cut: ignoring every bug still won — bugs marching 1 tile/turn could never reach the north row inside five turns. A genre scenario needs BOTH proofs: a line of play that wins AND a null strategy that loses. (Same shape as Rootward’s undefended-lane check; now a standing pattern.)
- Push-redirect is state-relative telegraphing: storing the telegraph as a DIRECTION on the bug (not a target tile) is what makes pushing rewrite the future — the entire genre falls out of that one representation choice.
- 1-ply greedy + structuredClone is a real tactics baseline. ~80 options per mech activation, scored on a cloned state (prospective telegraph damage weighted -90) — enough for a perfect clear of a fair scenario, no search tree needed. Pure-data state makes clone-and-score trivial; THIS is where the plain-object discipline pays.
- Turn-based command interfaces (select/cursor/move/attack/end) drive equally well from keys and from verify scripts — the VtCmd union is the sim’s real API, input actions are just one binding of it.
13 · Glimmerfall — match-3 (Puzzle Quest-ish) ✅
Shipped: 8×8 six-color board, grab-and-swap input, line matches, gravity, rng refills, cascade combos (×combo scoring), dead-board reshuffles, a 22-move / 1300-light goal. Verified: 100 fresh boards fair (no pre-matches, always a move), the resolve script accounts for every point, a greedy matcher hits the target on 13/20 seeds, scripted session golden-pinned.
Findings:
- Instant-sim + animated-view is the right split for cascade games. The sim resolves a whole cascade in one deterministic step and RETURNS the choreography script (what cleared at each combo depth); the view springs gems toward their true slots and reads the script for bursts. Delete the animation and the game is bit-identical — the strongest possible form of the cosmetic-layer rule, and it makes cascade correctness trivially testable (no animation timing in tests, ever).
- Score accounting as an invariant:
score === Σ cleared×10×comboover the resolve script catches any double-count/refill-scoring bug forever. Economies should always ship with their own bookkeeping audit. - Match-3 winnability is a distribution, not a bound. The greedy matcher measures the luck-adjusted difficulty; the target was tuned by win-rate (2/20 at 2200 → 13/20 at 1300). In luck-heavy genres, tune the GOAL to the measured bot distribution rather than the mechanics to a fixed goal.
- Board-generation fairness (no pre-matches + guaranteed move + reshuffle rescue) is the genre’s connectivity proof — same slot as Hollowdeep’s BFS.
15 · Fernrow — farming/life sim (Stardew-lite) ✅
Shipped: a 16-day year over four seasons, energy-budgeted days (24 actions), till/plant/water/harvest on a 10×6 farm, three season-locked crops, overnight growth, unripe-crops-wither on season change, a 700-coin festival goal. Verified: a diligent bot wins on day 12 (30 harvests); no-water-no- growth; wither honesty; the energy bound; reinvestment compounds (740 vs 236 coins); golden year replay.
Findings:
- Calendar arithmetic is a solvency constraint. First cut: 3-day seasons with 2-3-night crops meant beans could NEVER mature (planted day 1 of summer, ripe the morning autumn withers them) — the bot ended the year with 4 coins and the ‘compounding’ comparison read 4 vs 4. Season length ≥ longest growDays + harvest slack is a hard inequality; check it the way Shard Ascent checks jump distance.
- ‘Don’t plant what can’t mature’ is player knowledge the sim shouldn’t hide: the bot needed a nights-left-in-season guard to stop donating seed money to the wither. A kind UI would surface exactly this number.
- Ripe crops surviving the season turn is the difference between a
punishing calendar and a gentle one — one predicate
(
grown < growDaysin the wither rule) sets the genre’s whole mood. - Economy proofs transfer straight from Lumen Forge: goal tuned to the measured bot yield, plus a reinvest-vs-hoard delta (the farming version of ‘drafting matters’).
16 · Palewood — survival horror (Darkwood-lite) ✅
Shipped: one 90-second night: a raycast-shadowed lantern (radius + LOS —
trees cast real darkness), fuel that drains and cans that force fetch runs,
Pales that stalk the dark and flinch from light, wound/grace grabs, panned +
distance-attenuated growls (new engine audio.spatial, StereoPanner), a
dread heartbeat that quickens. Verified: the keeper survives to dawn burning
all 5 cans; fuel arithmetic proves camping impossible AND the night winnable;
light-repels and darkness-kills each proven; deterministic + golden.
Findings:
- Horror difficulty lives in the resource arithmetic, not the monster. Every keeper death traced to fuel economics: fetch-early wasted refuel overflow against the tank cap (all cans gone by t=35, dry at 47); the discipline that survives is ‘refuel only below max−refuel’. The genre’s dread loop IS an economy — same audit tools as Lumen Forge/Fernrow apply.
- LOS-shadows create ambush zones; physicality keeps them fair. Pales that could walk through trees became invisible adjacent killers (unlit by LOS inside the copse). Making monsters collide — including their knockback recoil, which otherwise embeds them in trees and freezes them — turned shadows from cheap deaths into readable threats. Rule: anything that interacts with light must interact with the light’s occluders.
- Wound + grace beats instadeath (the i-frames family again, at horror pacing): one grab is a story, two is a death.
- Spatial audio was a 20-line engine addition: pan from horizontal
offset, gain from distance², all driven by sim events (
ev.growl) — the no-op-in-Node invariant holds, so headless verification is untouched.
17 · Tarnholm — city/colony builder (Islanders × Mini Motorways) ✅
Shipped: procgen island (water rim, forest patches, grassy heart), an 18-building queue, adjacency scoring (huts cluster, farms want open grass, sawmills forest, docks water, temples love huts and hate industry), live score preview under the cursor, a 150-renown target. Verified: 50 islands always fit the queue; greedy placement wins 20/20 at avg 158 (tight); greedy nearly doubles random placement (158 vs 82 — skill is real); scoring honesty audited; deterministic + golden.
Findings:
- The live ‘+N’ under the cursor is the entire genre UI. Islanders works
because the scoring function is EXPOSED, not discovered —
placementScoreserves the sim, the verify suite, the greedy bot, and the cursor label from one function. When a genre’s core rule is one pure function, put it on screen verbatim. - Skill-delta proofs generalize: greedy-vs-random is the placement version of Thornspire’s draft-vs-skip and Fernrow’s reinvest-vs-hoard. Every strategy genre now ships an assertion that playing WELL matters — arguably the closest thing this campaign has to a mechanical ‘fun proof’.
- Negative synergies (temples hating industry) create the only real decisions in the queue — pure positive-sum scoring plays itself. A design smell worth remembering: if the greedy bot never faces a tradeoff, neither does the player.
18 · Cadence Hollow — rhythm (Crypt of the NecroDancer-lite) ✅
Shipped: a beat-locked dungeon chamber: 120 BPM = exactly 30 fixed frames per beat, moves legal only inside a ±4-frame window (one per beat), foes act in lockstep on beat ticks, combos build on-beat and shatter off-beat, floor and metronome pulse read straight from the frame counter. Verified: a beat-perfect dancer clears the chamber; the window honest TO THE FRAME (+4 in, +5 out); one-action-per-beat; foes provably frozen between beats; the whole dance replays hash-identically.
Findings:
- The genre’s determinism paradox dissolves by inverting the dependency: THE BEAT IS SIM TIME. BPM chosen so a beat is an integer number of fixed frames; the timing window is frame arithmetic; the music is an observer that schedules tones off the sim’s beat counter. Nothing about rhythm requires an audio clock in the sim — the audio clock is a RENDERER. (In a shipping title you’d lookahead-schedule Web Audio from the driver to hide rAF jitter; the sim contract is unchanged.)
- Rhythm = an input-legality filter over a turn-based game. Cadence is Hollowdeep with a when-may-you-act rule; the whole genre layer was ~30 lines (beatOf/onBeat/one-act-per-beat). Genres compose.
- Frame-exact window tests are the whole feel contract: +4 frames accepted, +5 refused, hammering inside one window acts once. These three assertions define ‘tight but fair’ better than any playtest adjective.
19 · Pinshine — physics arcade (Breakout roguelite / Peggle) ✅
Shipped: a Peggle-ish board: aim fan + trajectory preview, gravity flight, SWEPT circle-vs-circle collision (closed-form time-of-impact per substep, up to 3 impacts resolved per substep for corner rattles), restitution bounces, a patrolling refund bucket, 10 orange goals in 8 balls. Verified: an aim-searching sharpshooter (49 candidate aims simulated per shot on cloned states) clears the board in 7 balls; a 24,000px/s ball cannot tunnel and a 1px graze correctly misses; bounces never add energy; golden replay.
Findings:
- Swept collision is a quadratic, not a subsystem. |v|^2 t^2 + 2(d.v)t + |d|^2 - R^2 = 0, take the earliest root in [0, dt] — one function gives exact, speed-independent contact. The ‘no tunneling ever’ guarantee costs ~15 lines; substepping alone would have needed tuning forever.
- Physics games get shot-planning bots for free from pure state: clone the state, fire a candidate aim, run the flight, count oranges — the same structuredClone pattern as Vantage’s tactics scoring. Winnability proof AND a difficulty meter (7 of 8 balls needed = tight board) in one.
- Energy honesty as an invariant: ‘a bounce never leaves the ball faster than it arrived’ catches restitution/normal bugs that look like liveliness. Feel-critical physics deserves conservation checks, not just trajectory eyeballing.
20 · Vellgrove Rally — top-down racing (Micro Machines-lite) ✅
Shipped: whole-track fixed camera, arcade handling (thrust, hard lateral grip, high-speed UNDERSTEER, grass drag), a 12-waypoint circuit with ordered-checkpoint laps, two racing-line rivals (seek-ahead + brake-for-bend), countdown start, live positions. Verified: the line finishes 3 laps in 26.5s; braking beats flat-out (26.2 vs 27.7 — cornering is a real skill); infield cutting advances nothing; grass caps speed at 17%% of tarmac; the player-bot wins P1 through the input layer; golden grand prix.
Findings:
- Racing feel is an inequality chain: turn radius (speed/steer-authority) vs corner radius vs track width. With full authority at speed, flat-out made every bend and braking was pointless — the fix wasn’t more grip but UNDERSTEER (authority falls past 240px/s), which makes the speed/line tradeoff physically real. Same lesson family as Fernrow’s calendar and Shard Ascent’s envelope: derive the design constraint, don’t vibe it.
- Ordered checkpoints are the whole anti-cheat: only the NEXT waypoint counts, so the infield is worthless by construction — one comparison, no path validation.
- The rival AI is the difficulty dial and the proof in one object: the same driveLine() drives rivals, proves lap-completability, measures the skill delta, and pilots the player-bot through the input layer.
21 · Emberreign — narrative decisions (Reigns-lite) ✅
Shipped: four meters between two ditches, a 15-card data-driven deck (every choice double-edged), flag-chained story arcs (the plot you pay to learn of vs the one that springs), eight themed dooms, survive-12-years victory. Verified: a balanced regent survives 19/20 reigns; always-left wins 0/20 (avg 17 seasons) — judgement is the game; content lints clean (unique ids, bounded effects, all arc flags settable); every doom fires its OWN ending; the plot arc terminates both ways; deterministic + golden.
Findings:
- When content is data, editorial judgement becomes CI. The content lint (double-edged options, |effect| ≤ 20, every needs-flag settable somewhere) catches the classes of authoring mistakes that silently break narrative games: dead arcs, no-op choices, meter nukes. A writers’-room checklist as assertions.
- The doom-attribution audit matters more than it looks: each of the 8 endings must fire from ITS meter at ITS edge — a swapped ending string is invisible to play-testing (you died, a doom showed) but wrecks the fiction.
- The whole genre is the balance-seeking bot’s world-view inverted: the regent policy is ‘minimize the worst meter’s deviation from 50’ — Reigns is fun precisely because cards make that impossible to do forever. The 19-vs-0 policy delta is this campaign’s final and cleanest ‘decisions matter’ proof.
14 · Lumen Forge — incremental/idle (Paperclips × Cookie Clicker) ✅
Shipped: 5-tier exponential economy (lantern → dawn engine), forge clicking, unlock-by-lifetime-total reveals, DOM shop sidebar, pulsing-forge SVG view with orbiting fireflies. Balance sim–verified 10-minute arc: first buys at 3s / 71s / 236s / 402s / 607s, era gaps 69→164→166→205s.
Findings:
- The verify suite IS the game design for this genre. Pacing windows, monotone production, no unlock deserts, no click-softlock — all asserted, so any retune that breaks the arc fails CI. Tuning was three verify runs: the greedy bot exposed that rising payback ratios (cost/prod per tier) strangle the late game; compressing paybacks to ~15–25s across tiers fixed it. No human playtest could have produced this signal in minutes.
- Engine gap found + fixed: UI clicks weren’t inputs. Only KeyboardSource
existed — a DOM buy button had no deterministic path into the sim. Added
input.press(action)virtual taps (held until ≥1 fixed step samples them, cleared by the driver) and exposedinputon GameHandle. Clicks now live in the same replayable input log as keys. Rule: UI intent must be an action, never a direct state mutation — else record/replay silently lies. - Genre note: idle games exercise almost none of the scene tree and all of
world.state+ probes. The cosmetic view (log-scaled pulse, entity count as wealth display) matters for feel but the DOM is the real interface. A persistent HTML side panel (not modal showScreen) was needed; candidate for a ui/ “panel” helper if a third game wants one. - Gap deferred: offline progress needs wall-clock at the driver boundary (store last-seen, feed elapsed as sim ticks on load) — designed but not built; revisit if a sim/farming game needs calendar time.
1 · Sokoban (v0.1 baseline)
Proved: pure Puzzle module + BFS solver + assertDeterministic + scripted
playthrough. Weakness identified: everything about this engine was only ever
exercised by a discrete grid puzzle. Hence this campaign.
22 · Rookspire — physics demolition (Angry Birds × Crush the Castle) ✅
Engine first: this game forced hayao’s biggest engine upgrade — a full
deterministic 2D RIGID-BODY module (src/physics/rigid*.ts, exported via
@hayao): circle/convex-poly bodies, SAT narrowphase with clipped 2-point
manifolds, warm-started sequential impulses (restitution, Coulomb friction),
SPLIT-IMPULSE penetration recovery (pseudo velocities — zero kinetic energy
injected), distance + revolute joints with motors and limits, swept-circle
bullet CCD, island-atomic sleeping, contact events with impulse magnitudes,
ray/point queries. The whole world is PLAIN JSON — it lives in world.state
and inherits hash/snapshot/replay/structuredClone-bots for free.
Shipped: three castles (tower / twin rooks + lintel / walled keep with a rope-hung idol), aim-arc slingshot, materials (wood/stone), idol destruction by contact impulse or earth-touch, rubble that stays. Verified: an aim-searching siege bot with a structural-displacement gradient proves every castle falls within its stone budget; a full-power flat shot proven to strike (CCD); settled castles proven to SLEEP (0 awake → 0.03ms/step); golden replay; feel probes (impact wakes the pile, dust settles in 4.3s).
Solver-class findings (each found by a failing gate, none by eyeballs):
- Warm-start capture order is an energy pump. Computing a point’s restitution approach-speed AFTER earlier points’ warm impulses were applied double-counts the bounce; any restitution > 0 made piles wobble forever. Box2D’s two-pass shape (capture ALL, then warm-start ALL) fixed piles at every restitution. The class: interleaving read-and-apply over coupled constraints.
- Baumgarte bias velocity cannot sleep. Position error fed into the velocity solve holds stacks but leaves them humming at 5–20px/s — sleep never engages. Split impulse (Chipmunk bias velocities) recovers overlap in a pseudo field that dies at integration; piles then freeze truly. Linear-only pseudo pushes: rotating positions without refreshing manifolds re-rocks the pile.
- Sleep must be island-atomic. Per-body sleep in a pile is unreachable — neighbors wake each other forever (a 10-box tower NEVER slept). Union-find islands over dynamic contacts + joints, sleeping a whole island when every member is calm, put a settled tower to sleep at frame 56.
- CCD has three pinning traps: (1) clamping to exact TOI leaves pen=0 and the discrete narrowphase (pen>0) never sees a contact — gravity pumps velocity to thousands while the body hangs (fix: advance 0.4px past TOI); (2) exit crossings of the Minkowski hull count as hits and pin a bullet to the platform it launched from (fix: front-side crossings only); (3) a ball SLIDING along a face it already touches re-clamps at t≈0 every frame (fix: skip faces the start point already touches — that regime belongs to the contact solver).
- Feel probes catch design, not just code: “impact wakes the castle” failed on a clean headshot (physically correct, cinematically wrong) — the golden script was retuned to a body shot. “Dust settles” failed because the sim FROZE at the win screen; terminal states now keep settling physics.
23 · Brasswick — pinball (brass-parlor table) ✅
Shipped: felt-and-brass table, three bumper-bells with impulse kicks and a jackpot relight, kinematic flipper blades driven about their pivots, drain sensor, three deterministic serves, first-to-2000. Verified: a pulse-flip bot wins in 13s (peak ball speed 2445px/s); the ball proven table-bound for the whole game (CCD in tight geometry, 0 escaped frames); a searched flip proven to return a blade-rolling ball to the bumper field; every unflipped serve proven to drain (no dead pockets); golden metronome rally; feel probes (first bell 0.4s, ball lives 5.2s, max lull 4.7s).
Findings:
- Flippers want to be kinematic, not motored joints. A revolute motor drives RELATIVE angular velocity about centroids; the pivot constraint eats most of it (3.7 rad/s of a 26 target) and tuning is a war. A kinematic body whose pose+velocity are recomputed about the pivot each step snaps in 3 frames, is infinitely stiff (as a paddle should be), and the contact solver imparts its true surface velocity to the ball for free.
- “An unflipped table always drains” is the pinball winnability proof. Dead pockets are this genre’s unwinnable-level bug: the gate caught a ball CCD-pinned mid-slide on a corner slant (engine trap #3 above) that looked exactly like a table-design flaw.
- Cradling emerged, unprogrammed. Hold a flipper up and the ball nests in the funnel–blade crotch — trap-and-hold, a real pinball skill, straight out of the dynamics. Bots must PULSE flips or they cradle forever; verify bots are players and need player discipline (hold/cooldown timers).
- Feel windows come from judged runs. The 6s ball-life gate was invented; the honest metronome run lives 5.2s with 0.4s first-score and 4.7s max lull — a living table. The gate now encodes the judged run, not a wish.
E1 · Engine primitives — FSM, weighted tables, graph search (js13k-mined) ✅
Not a game — the “engine gaps first” step run standalone, filling the cleanest
pure-logic wins from JS13K-MINING (rows #4/#5/#6, and an
assessment of #17). One new module dir, src/logic/, three files, 19 tests,
all deterministic and hash-safe (no cosmetic concerns — pure logic only).
Gaps filled:
logic/fsm.ts(#4).Fsm— onEnter/onUpdate/onLeave states + an ordered transition table (first satisfied guard wins; order IS the tie-break, so it stays hash-stable). PlusPhaseClock, the pure-logic↔cosmeticbridge generalised from super-castle-game’sIState+NextPhaseMap: a timed phase with anext-map and an easedprogress()0→1, so discrete logical steps and smooth view interpolation ride the same fixed-dtclock. Both serialize to a plain key(+timer) forworld.state.logic/random.ts(#5).weightedIndex/weightedPick/pickEntry+ aLootTable(prefix-summed for cheap repeat rolls), all drawing onerng.float()per pick from a passedRng—world.rng, neverMath.random(the corpus’s near-universal sin). Negative weights clamp to 0; all-zero tables throw rather than silently mis-sample.logic/graph.ts(#6). Genericbfs+reconstructPath, weightedastar/Dijkstra over any adjacency fn, and grid conveniencesfloodFill,connectedComponents,astarGrid,passableFromTilemap. Determinism came from three specifics: fixed neighbour order (NEIGHBORS_4/8), row-major component scan, and an A* min-heap tie-broken by a monotonic insertion counter — without that last one, equal-fnodes pop in engine-dependent order and the chosen path (not just its cost) desyncs across machines. Heuristics are integer-stable (Manhattan / octile) for the same reason.
Decision — ECS-lite behavior hooks (#17) deferred, not built. norman’s
Behaviour{onUpdate,onCollision,onDamage} mixin earns its keep in a codebase
without a scene graph; Hayao already has one (Node.onProcess + Signal/
EventBus for collision/damage), so a parallel per-entity hook bus would be a
second, competing update path — exactly the “keep the node tree lean” trap the
row flagged. The composition it offers is already expressible as small Nodes or
world.state behavior tags iterated in onProcess. Revisit only if a real
game hits friction the node tree can’t absorb; until then it’s redundant
surface. (Row #17 marked evaluated → declined in JS13K-MINING.)
Findings:
- The determinism cost of graph search is entirely in the tie-breaks. BFS
is free (array frontier, insertion order). A*/Dijkstra is not: a min-heap
ordered on
falone lets equal-cost paths resolve by heap-internal swap order, which differs per engine — same length, different cells. A monotonicseqas the secondary key makes the path itself reproducible. Heuristics must be integer/exact-arithmetic too, or float wobble re-orders ties. PhaseClockis the missing half of the tween kit.AnimationPlayereases cosmetic values;PhaseClockeases the timing of logical commits and hands the view a progress alpha to interpolate against — the piece 5 of the sampled games hand-rolled with ad-hoc timers.
E2 · Procgen generators + color engine + ambient particles (js13k-mined) ✅
Second “engine gaps first” batch — JS13K-MINING rows #7,
#8, #14. Unlike E1 (pure logic only), this batch spans the whole determinism
spectrum, and the discipline was drawing the line per output: logical
structure is hash-relevant and runs off world.rng; decoration and view are
cosmetic and stay out of the hash. New src/procgen/ dir (5 files),
art/palette.ts + scene/particles.ts extensions, one core/math helper.
31 new tests, all green; headless SVG screenshot verified.
Gaps filled:
procgen/— carving + scatter (#7).generateCave(cellular automata, space-huggers caverns) andgenerateDungeon(room+corridor bases) both take a passedRngand iterate row-major, so same seed → byte-identical grid (Grid→gridToTilemapdrops straight into physics — these ARE hash state).terrainHeightis the knight-dreams endless-terrain case done right: a stateless pure function of(col, seed)via layered value noise — no PRNG stream, any column sampleable in any order, infinite worlds reproducible for free.scatter.tsis the witchcat/cat-survivors((x-812347*y)*…)%17trick generalised:cellHash/scatter/valueNoise/fractalNoise, all stateless coordinate hashes → cosmetic decoration, no PRNG state threaded.art/palette.ts— HSL/HSV + gamma-correct blends (#8).hsl/hsv/hexToHslconstructors (pure arithmetic, no trig),mutateColor(space- huggers hue/sat/light drift viaworld.rng), and linear-lightmixLinear/sampleGradient/gradient(super-castle’s gamma-correct lerp). The sRGB↔ linear 2.4-exponent routes throughdmath(dexp2/dlog2) — a localdpow— so it’s bit-identical across engines AND never trips the “noMath.pow” invariant.scene/particles.ts— ambient field preset (#14).AmbientField, a screen-wrapping drift field (a distinct node from the burstParticles): fixed particle set seeded across a region, toroidal wrap, ownRng, alwayscosmetic.weatherEnvelopeis a smoothstep keyframe curve over sim time (world.time, never wall-clock) that thins the field to fade weather in/out.AMBIENT_PRESETS.snow/rain/ash. Addedsmoothsteptocore/math.
Decision — no simplex/Perlin lib. Per the mining doc’s anti-recommendation, value-noise (bilinear-smoothed integer-cell hash) + integer scatter cover every sampled game’s real need at a fraction of the size; a heavyweight simplex import would be cosmetic weight for no gameplay dependency. Declined on purpose.
Findings:
- The hash boundary in procgen runs per-output, not per-module. The same
src/procgen/dir holds both hash-critical state (generateCave/Dungeon/terrainHeight→ collision geometry, fully deterministic offrng/seed) and cosmetic-only helpers (scatter/valueNoise→ decoration). The invariant isn’t “procgen is cosmetic” or “procgen is hashed” — it’s which output are you producing. Structure carves logic; scatter dusts the view. - Stateless coordinate-hash beats a seeded stream for endless content.
knight-dreams’ anti-pattern isn’t just
Math.random— it’s stateful generation, which forces you to generate the world in order. A puref(col, seed)height (value noise) needs no stream, so an infinite runner can sample any column, any order, any peer, and agree — determinism as a property of the function, not a discipline you maintain over a mutating generator. - Gamma correctness and the determinism invariant point the same way. The
honest sRGB→linear blend needs a 2.4 power;
Math.powis both perceptually required here and engine-nondeterministic (banned). Buildingdpowondexp2/dlog2satisfies both at once — the “correct blend” and the “reproducible blend” turned out to be the same fix.
E3 · Presentation / game-feel primitives (js13k-mined) ✅
Third “engine gaps first” batch — JS13K-MINING rows #9,
#11, #12, #16. All four are view/juice: transitions, spring-smoothed values,
damage popups, and UI frames. The whole batch is cosmetic-by-construction —
nothing here enters world.hash() — and every effect runs off the fixed clock
dt, never a variable rAF delta. New ui/transition.ts, render/nineSlice.ts,
scene/floatingText.ts, scene/tween.ts + core/dmath.ts extensions. 28 new
tests, all green; a headless filmstrip renders the wipe + panel + pops + spring.
Gaps filled:
ui/transition.ts— wipes + cinematic sequencer (#9).ScreenTransitionis a cosmetic node that paints a full-screenfade/circle(iris)/ditherwipe in screen space (draws withIDENTITY, so it rides over the camera untouched), driven by a queued ramp machine ondt.wipe()is the classic cover →onMidpoint(swap the level) → reveal, gated offbusy. The dither dissolve is an ordered 4×4 Bayer threshold — pure, no PRNG.CinematicPlayerwalks pure-dataCinematicStep[]: each step’senterfires once, then it holds untildurationelapses and itsuntilgate passes —wipeStep()wires the gate to!transition.busy, which is exactly witchcat’s fade-gated advance. NamedScreenTransitionto avoid colliding withlogic/fsm’sTransition.scene/tween.ts+core/dmath.ts— framerate-independent smoothing (#11).lerpDamp(current, target, lambda, dt)foldsdtinto an exponential so the same real-time response falls out of any fixed step (dante).spring/springStepis a closed-form critically-damped spring (no stiffness blow-up, no overshoot at any dt; cat-survivors/witchcat feel), andmakeReachis the one-liner chase that owns its velocity in a closure. All route through a newdexp(natural exp viadexp2) so they’re bit-identical across engines — safe on hashed values, not just cosmetic ones.scene/floatingText.ts— pooled damage numbers (#12).FloatingTextis the rise-and-fade combat-text emitter every action/survivors/RPG expects, built as the exact twin ofParticles: pooled, capped,pop()on sim events, opacity fade over the tail of life,FLOAT_PRESETS.damage/crit/heal/label.render/nineSlice.ts— scalable panels (#16).nineSlice(rect, style)is a pure display-list helper: a 3×3 grid where the four corners stay a fixed size while edges stretch and the center fills, so a frame at any size keeps crisp corners. Distinct edge/corner/highlight/shadow fills give a free bevel;PANEL_PRESETS.parchment/slate. Border auto-clamps to half the smaller side.
Decision — floating-text jitter uses a private Rng, not world.rng. The
mining row said “jitter via world.rng”, but the stronger in-repo invariant
(particles.ts: cosmetic juice carries its own stream so it “can be deleted
without changing any game outcome”) wins. A cosmetic node drawing from
world.rng would perturb the hashed RNG state, so toggling popups off would
diverge the sim — the precise anti-pattern the separate-stream rule exists to
prevent. FloatingText seeds its own Rng like Particles/Shaker.
Findings:
- “Cosmetic” is a rendering discipline, not just a
hash()opt-out. A screen wipe wants screen space, but every node is handed a camera-composed world transform. The fix is for the overlay to ignore the transform it’s given and emit withIDENTITY— the same instinct as the private-Rng call: cosmetic view must be able to change (or vanish) without moving anything the sim can observe, and that means opting out of both the hash and the camera. - Closed-form beats iterative for framerate independence. The naive
x += (target-x)*kis frame-coupled by construction; the analytic exp-decay / critically-damped solutions make “same real time → same result” a property of the formula, so they’re correct at 30Hz, 60Hz, or one giant catch-up step — the same lesson procgen learned with statelessf(col,seed).
E4 · Persistence & content-engine primitives (js13k-mined) ✅
Fourth “engine gaps first” batch — JS13K-MINING rows #1
(save/load — the single most-frequent gap, ~12/17 games), #13 (undo /
record-replay), #15 (data-driven content DSL). The unifying theme is plain-data
over the existing snapshot seam: save/load, undo, and the wave director all
build on world.snapshot()/restore() and store their cursor state as JSON, so
nothing here reinvents serialization or escapes the hash. New persist/storage.ts
persist/codec.ts+persist/save.ts,logic/history.ts,content/dsl.ts. 46 new tests, all green;npm run verifyunchanged (no golden drift).
Gaps filled:
persist/— save/load over a pluggable adapter (#1).StorageAdapteris a four-method (get/set/remove/keys) seam — the only browser-coupled piece of the feature.LocalStorageAdapteris guarded on every call (private mode, quota, SSR all degrade to a silent miss, never a throw);MemoryStorage/NullStoragekeep headless runs clean;defaultStorage()feature-probes a real write/read/remove rather than trusting the global, so a runtime that exposes alocalStorageobject whose methods throw (our own Node test env does exactly this) falls back to memory.SaveManagerridesworld.snapshot()/restore()—serializeSnapshotversions the envelope,parseSnapshotguardsJSON.parseand shape-checks so a corrupt slot is a cleanfalse, world untouched.codec.tsadds the js13k byte-squeeze tools — a reversible RLE (witchcat) and base-64 continuation-varint pack (soul-surf), both pure with exact round-trips.logic/history.ts— undo + ghost trails (#13).UndoStack<S>is a bounded memento stack with redo and a cursor (dying-dreams clones itsPuzzleState); it clones in and out so callers can’t corrupt history, and drops the oldest entry pastlimit.RingBuffer<T>is the O(1) bounded FIFO for echo/ghost trails (soul-jumper’s 8-frame history),toArray()oldest→newest for a trail renderer.content/dsl.ts— declarative content (#15).SpawnDirector(initDirector/pollDirector) interprets a flatWaveDef[](norman) with repeats, an end bound, and optional weighted spawn sets rolled viaworld.rng(cat-survivors’ director) — itsDirectorState.next[]cursor is plain JSON, so it snapshots/hashes like everything else and a poll after restore doesn’t re-fire.availableUpgradesgates evolution/upgrade trees on owned prerequisites +maxStacks(cat-survivors evolution, deckbuilder upgrades). Weighted rolls reuselogic/random’spickEntry/WeightedEntry— the E1 primitive — instead of a private copy.
Findings:
hash()wasn’t JSON-stable — save/load was the first thing to cross the boundary and prove it. A liveSpriteserializespaint:{fill:undefined,…}; JSON drops undefined keys, so a restored sprite serializedpaint:{}andhashValue— which counted anundefined-valued key as present — gave a different hash after a save round-trip. The fix is one line incore/hash.ts: omit undefined-valued object keys, matching JSON’s own semantics ({a:undefined}≡{}). It rippled zero goldens because well-built examples mark pure-view spritescosmetic(out of the hash entirely) — the bug only bites a non-cosmetic node in the hashed tree, which is exactly the save scenario. Determinism hazards hide behind the boundaries nothing has crossed yet.- Feature-detect the environment, don’t sniff the global.
typeof localStorage !== 'undefined'is not enough: our Node test runtime exposes alocalStorageobject whosesetItemthrows. Only an actual probe write is honest, and it’s the same instinct as the hash finding — trust behavior you verified, not a shape you assumed. - The snapshot seam pays compound interest. Save/load, undo, and director
restore are all ~30-line modules because
world.snapshot()/restore()+world.state-as-plain-JSON already did the hard part. The batch’s real work was guarding the JSON edges (corrupt slots, undefined keys, broken storage), not serialization — the pure-state model keeps handing back primitives for near-free.
E5 · Art-from-code primitives — procedural sprites, bitmap text, autotiling (js13k-mined) ✅
Fifth “engine gaps first” batch — JS13K-MINING rows #2
(procedural sprite/texture generation, 6 games), #3 (bitmap/pixel font + rich
text layout, 6 games), #10 (autotiling — bitmask Wang + marching squares). The
unifying theme is view over data: every module is a pure function to a
DrawCommand[], and the three scene nodes it ships (TextureSprite,
BitmapText, and the autotile emitters) are the only stateful surface — each
cosmetic = true in its constructor, so decoded pixels / rendered glyphs /
tile art never enter world.hash(). New art/texture.ts, art/font5.ts,
art/bitmapFont.ts, art/autotile.ts. 21 new tests, all green; npm run verify unchanged (no golden drift); headless SVG proof composes all three.
Gaps filled:
art/texture.ts— sprites/tilesets from ~0 asset bytes (#2).PixelBufferis a tiny indexed bitmap; three decoders reconstruct one from a compact encoding —decodeBits(1-bit MSB-first BigInt/hex, super-castle),decode2bit+PixelBuffer.remap(lut)(2-bit source → palette LUT, dying-dreams),decodeRLE/encodeRLE(run pairs, witchcat, exact round-trip).pixelsToCommandsprojects a buffer to run-mergedrectcommands (a 16×16 sprite → a handful of rects, not 256), skippingnullswatches as transparent.TextureSpriteis the drop-in cosmetic node.art/font5.ts+art/bitmapFont.ts— pixel text with rich markup (#3). A built-in 5px proportional font (Iis 1 wide,Mis 5).parseRichreads a{tag}…{/}colour stack (clawstrike/coup-ahoo),{{→ literal brace,#hextags literal.layoutTextword-wraps + breaks on\nand is a pure function of (font, string, width);typewriterCountis a pure function of sim time.BitmapTextdrives the reveal offworld.time(neverperformance.now) and is cosmetic.art/autotile.ts— seamless tiles from a boolean grid (#10, fully pure).mask4/mask8are the neighbour bitmasks; a module-load-once 16-entry table maps each 4-bit mask →{frame, rotation}(isolated/cap/straight/bend/tee/ cross × quarter-turn) by rotating one canonical mask per frame.marchingSquaresCases+marchingSquaresContoursgive the corner-sampled dual-grid alternative (iso-contour segments, saddle cases fixed). Emitters draw fill + exposed-edge seams and the contour polylines.
Findings:
- The two autotilers read the same grid through different lenses — align by
tile/2or they talk past each other. The bitmask autotiler treatsgrid[y][x]as a tile (a filled cell); marching squares treats the same value as a corner sample (a point). Overlay them at the same origin and the contour sits half a tile off the fill — visible immediately in the headless proof. Neither is wrong; they’re dual grids offset bytile/2. Shipping both in one module is only safe if the offset is documented at the seam, because a caller who assumes “same grid → same coordinates” gets a subtly broken frame that still renders. - Make
cosmeticthe constructor default, not a caller checkbox. These are the most tempting primitives to leak into the hash — a decoded sprite looks like state. Emitting plainDrawCommand[]from the pure functions makes the hash-safe path the only path for the data (commands are never hashed), and settingthis.cosmetic = trueinsideTextureSprite/BitmapText(rather than asking the caller to remember) means a forgetful game can’t pollute determinism even by accident. The invariant is enforced by construction, not by discipline. - A pure typewriter needs no accumulator. Reveal count is
f(world.time − startTime, cps); the node stores onlystartTime. There’s no per-frame counter to snapshot, so scrubbing to any frame — or replaying on a peer — shows the exact same characters. Clock-as-input beats clock-as-state the same wayf(col, seed)beat the stateful generator in E2.
E6 · Camera decoupled from the screen — Camera2D follow, scroll, screenToWorld ✅
Sixth “engine gaps first” batch, prompted by a direct question: can the camera
be decoupled from the screen for scrolling and zooming? It already could — the
render root applies World.viewTransform() (the inverse of the active
Camera2D, centred in design space and scaled by zoom) to the whole display
list, so moving a Camera2D node scrolls the world and setting its zoom
scales it. What was missing was the ergonomic + proof layer: nothing drove
the camera, nothing converted screen→world, and no example exercised a world
larger than the viewport. Filled that. New scene/cameraController.ts and two
World methods; 10 new engine tests + a scrolling-camera demo. Full
portfolio verify unchanged (no golden drift on the other 26 games).
Gaps filled:
World.screenToWorld/worldToScreen. Three lines each over the existing transform kit (applyTransform(invertTransform(viewTransform()), p)), unit- proven to round-trip exactly under a scrolled + zoomed camera. The doc comment is a warning as much as an API: convert pointer coordinates at the host edge and feed the result in as a quantized action/axis — raw screen coords in the sim break determinism, which is why input stays action-based and no game loop calls this on the hot path.CameraController— follow behaviour on top ofCamera2D. Chases a target node with a deadzone (slack box the target roams before the camera moves), a per-step lerp, and world-bounds clamping (inset by half the visible extent, which grows as you zoom out) so the view never shows past the level edge. Itsnap()s on ready so frame 0 is already framed.cosmetic = trueby default.
What transfers:
- A follow camera is view, not state — default it cosmetic. The smoothed
chase is derived from the (canonical) target and would only fold float drift
into
world.hash(); a follow camera driven by a lerp will intermittently breakassertDeterministicif it’s hashed. SoCameraControllersetscosmetic = truein its constructor (the E5 “enforce by construction” lesson again), and the demo marks itsCamera2Dcosmetic too. The escape hatch is deliberate: a game where offscreen means something mechanical (scroll-death shmup, fog reveal) keeps the camera canonical. - Parent the HUD to the camera to pin it to the screen — for free. A child of
the
Camera2Drenders atview ∘ cameraWorld ∘ local = centered ∘ local: the scroll cancels out, so camera-children sit at fixed screen positions with no inverse-transform bookkeeping. Lanternway’s coach line and goal compass are camera children; the world scrolls under them. (Atzoom ≠ 1they scale with the zoom — counter-scale if you need pixel-fixed chrome.) - The edge-clamp makes the deadzone “leak” near corners — tune feel windows to a judged run, not the steady state. In the open field the bearer leads the camera by ~the deadzone; at the world corner the camera clamps and the bearer walks the rest of the way in, so the on-screen offset peaks well past the deadzone (420px vs 180px here). The feel probe window has to come from a run you actually watched, or it fails on correct behaviour.
E7 · Atmospheric render — gradient/glow paint + parallax depth (atmospheric demo) ✅
What the engine lacked. The Paint vocabulary was flat colour strings only:
fill/stroke hex, nothing else. That is the ceiling between “flat woodblock”
and luminous — no way to render a dawn sky, a lantern’s glow, water that
reflects light, or fog. The prior games are all flat-fill; the art toolkit
(shapes/texture/autotile) shapes silhouettes but can’t light them.
What was upgraded (all reusable, folded into src/render).
- Gradient fills —
LinearGradient/RadialGradientin OBJECT-BOUNDING-BOX space (coords 0..1 of the shape’s bounds, so one gradient reads on any size), carried as pure data onPaint.gradient. BuilderslinearGradient(stops, angleDeg)/radialGradient(stops, {cx,cy,r})take colours (auto-spaced) or explicit{offset,color}. SVG emits<defs>; Canvas maps the unit box to px. - Soft glow / drop shadow —
Paint.shadow = {color, blur, dx?, dy?};glow(c,b)(symmetric) anddropShadow(c,b,dx,dy). SVGfeDropShadow, CanvasshadowBlur. This single primitive is what makes the lantern and fireflies emit light rather than just be coloured discs. ParallaxLayer(src/scene/parallax.ts) — a cosmetic depth layer that sets its own position tocamPos·(1−factor), yielding an effective scroll of exactlyfactor; 0 pins to screen (far), 1 is full world scroll (near). No render-core surgery — pure scene-node math over the activeCamera2D.
Convention / traps this cost. SVG url(#id) references are DOCUMENT-global,
but the filmstrip composites N frames into ONE document — so gradient/shadow def
ids MUST be salted per render. commandsToSVGInner(cmds, idPrefix) now takes a
prefix; renderFilmstrip passes p{panel}. Without it, panel 3’s fill resolves
to panel 0’s gradient. Two more: (1) the Sprite constructor hand-copies paint
fields and silently dropped the new gradient/shadow until added — new Paint
keys need a line there; (2) serializeProps must return a COPY of any mutable
array (gathered.slice()) — returning the live reference lets a later mutation
corrupt an already-taken snapshot, surfacing only as a snapshot-restore hash
divergence, never in normal play.
What transfers. Every game gains gradients + glow for free — skies, water,
health bars, lit projectiles, rim-light. Because paint is cosmetic data on
cosmetic nodes it never enters world.hash(): the atmospheric demo recolours the sky every
frame and the golden replay hash is unchanged. The “looks judge sees only a
static SVG” gate still binds — all richness is static per frame (the day arc is a
function of downstream progress, not wall-clock), so the filmstrip reads true.
Driver game — Driftlight (ambient art-runner). A paper lantern auto-drifts a night river to the dawn sea; steer across-stream (↑/↓) to thread rock gates and gather firefly-light that feeds a draining flame (win: reach the sea lit; lose: flame out). The course is a deterministic chain of gates with rocks derived around each gap and lights strung on the safe line, so tracking the path both survives and refuels — the winnability proof. Flame tuned to a nervous sawtooth (a perfect line dips to 44%, ends 81%); a human who misses lights dies. Sim state (px/py/flame/gathered/won/lost) is hashed; sky/water/glow/parallax are cosmetic. 5 tests + full verify green, golden pinned.
Wave — the content-volume unlock: solver-backed generation + a generated flagship
The campaign proved hayao could make one verified level reliably; the honest gap was volume — an agent hand-authoring forty balanced rooms is where quality falls apart. This wave closes it by making generation first-class, then proving it with a flagship built entirely from generated content.
Loop 0 — generate, don’t hand-author. Three engine modules:
content/generate.ts—generateLevels(factory, {count, band}): build candidatePuzzles from a seeded rng,solve()each, keep only ones proven winnable inside a difficulty band. Genre-agnostic; each kept level carries the sub-seed that reproduces it, so a campaign ships as a list of seeds, not a folder of maps. A capped/exhausted search counts as “unknown, reject” — a level only ships proven.content/campaign.ts—composeCampaign: stitch per-act generators (each with its own band and optional own factory → mechanic/board changes as data) into an ordered, escalating campaign with an honest length estimate.verify/ramp.ts—rampIssues/assertRamp: prove the curve — escalation (finale at the peak), no cliffs, forward progress dominates, real variety. The campaign-level analogue ofassertSolvable.
Loop 2 — the flagship, Lanternfold. A Lights-Out lantern puzzle chosen because
its taps are self-inverse, so any scramble from solved is guaranteed winnable and
the solver’s minimum-tap count is a clean difficulty metric. 42 boards across four
acts, generated + solver-proven, composed into a ramp that climbs 2→8 taps, shipped
as levels.ts (verify asserts the committed data equals a fresh compose, so it
can’t drift). Art is code-as-art: radial-gradient lantern glow on a dusk-sky
gradient — never a placeholder square. Covers all six proof channels.
Depth ceiling, learned by probing. Plus-stencil Lights-Out saturates low (3×3/4×4 → min-depth ~7); a satisfying finale needed board-shape tuning (5×3 reaches 8 with cheap BFS) and monotone per-act bands. Lesson: set generator bands from the measured solution-depth distribution — the generator loudly fails an impossible band, which is the right failure.
Loop 1 — distribution for agents. create-hayao (npm create hayao) scaffolds a
runnable project whose starter already generates a proven campaign; scripts/eval.ts
(npm run eval) scores every game on the six proof channels + verified rate — the
AI-first KPI. llms.txt / AGENTS.md now foreground “generate + prove”;
docs/GALLERY.md frames the portfolio as proof-forward, not faith-based.
F1 · Kintsugi — the flagship metroidvania ✅
The gap it closes. Every prior example is a slice — one capability, one screen, sixty seconds. The roadmap’s honest admission was “an Ori-scale metroidvania is not yet in the box.” Kintsugi puts it in the box: a hand-authored, ability-gated, 30-room / 5-biome metroidvania with combat, four multi-phase bosses, adaptive music, a plotted arc, and a map — machine-proven completable and softlock-free.
The one engine gap, and why it’s the whole point. The metroidvania spine was
proven in miniature already (sproutveil’s negative gate proofs; gleamvale’s combat
with zero engine changes). The missing piece was a verifiable world model — and
it collapses onto an existing primitive: progression IS a Puzzle. State =
(region, pickups taken); a move is “traverse a gated edge” or “collect a pickup”.
So the engine’s solve() BFS proves a valid 100% seam order EXISTS, and — because
abilities are monotonic — enumerating the reachable state graph proves NO SOFTLOCK
(the only remaining hazard once one-way drops enter). Shipped as
src/content/worldgraph.ts (proveCompletable/proveFullCompletion/
findSoftlocks/reachableRegions/validateWorld) and merged first, on its own.
Geometry proven to honour the graph. Rooms are authored as data (a spec → 40×22 ASCII); a test asserts every room exit corresponds to a real graph edge and that seams are reciprocal (one-way drops exempted). The graph is the contract; the geometry can’t silently contradict it.
What “publishable” took, layer by layer. The Mender (procedural articulated
figure, 7 poses, gold-seam repair motif). Combat (swept hitbox, hit-stop,
i-frames, FSM enemies) — reused from the proven kit. Four guardians (one multi-
phase FSM escalating by HP, sealing its arena until slain). A per-biome adaptive
MusicDirector (key/mode/mood + threat layers) built on the audio bus so it
no-ops headlessly. Story beats (prologue, area cards, ability & guardian lines).
A fog-of-war map from the same graph. A looks pass against design/JUDGE.md —
biome-specific midground (grove trees / cistern dripstone / ember forge-chimneys /
sky clouds / heart roots) + ambient motes to kill the empty-void look.
Verified. Winnability is proven the metroidvania way (the graph solver, a stronger guarantee than any single bot run) + a deterministic tutorial romp with a pinned golden + first-screen layout-lint/control-hints + an opening filmstrip. Everything visual is cosmetic (world.state.kg is the only hashed truth). ~36 example tests; whole portfolio green.
What transfers. The lesson that carried the whole build: a flagship is a
scaling problem, not an invention problem, once the verifiable spine exists —
and the spine is a reduction to a primitive you already have. Progression graphs,
lock-and-key dungeons, tech trees, and quest chains are all the same Puzzle.