clone

a browser grid puzzle game where you push words around to rewrite the rules of each level

main
miau-is-true / docs / architecture-review.md
architecture-review.md 21.1K source

Runtime architecture review

2026-09-23. Scope: browser runtime, deterministic engine, mod/share data, test and authoring boundaries. This review records the baseline and the migration contract for the archived runtime-boundaries OpenSpec change. The visual design and world files have separate owners.

Baseline

The project has useful seams already: createGame() does not import DOM or canvas; mod data is validated before registration; sharing carries data rather than CommonJS source; engine snapshots support Undo and the solver; render_game_to_text() exposes playable state to browser tests. At review start, 170 Node tests and the production build passed.

The source tree's file sizes did not match its responsibility boundaries:

File Baseline lines Responsibilities combined Risk
src/stage.js 1,526 Scene state and transitions, all scene drawing, layout, keyboard/pointer/intent routing, DOM overlays High
src/render/board.js 660 Geometry, words, entities, sentence bars, facing, effects, loss state High
src/juice.js 572 Event interpretation, tweens, facing/Undo history, rule and automata effects High
src/engine.js 427 Validation, parsing, movement, automata, contacts, snapshots, event assembly High
src/main.js 278 Bootstrap, catalog and packs, DOM view, game lifecycle, second developer renderer, audio High despite line count
src/scenes/map.js 335 Progress storage, map layout, navigation Medium
src/mods.js 275 CommonJS evaluation, validation, normalization Medium, security-sensitive
src/input.js 258 Keyboard, repeat, swipe, gamepad, listener lifecycle Medium
src/style.css 1 minified line Most base application styling Medium for maintainability

Raw line count is a signal, not a target. main.js demonstrates the sharper problem: dense one-line lifecycle handlers hide branches and make changes difficult to review. Large files should be split at ownership seams rather than sliced into arbitrary line ranges.

Intended boundaries

Keyboard touch gamepadStage action routerApplication sessionDeterministic engineSettled state and eventsStage projectionCanvas renderers andanimationTrusted local CommonJSor shared dataData validation andnormalizationReplay and solver statekeyKeyboard touch gamepadStage action routerApplication sessionDeterministic engineSettled state and eventsStage projectionCanvas renderers andanimationTrusted local CommonJSor shared dataData validation andnormalizationReplay and solver statekey

The session owns the game instance and authoritative state. A stage view is a projection of that state plus animation frames. A renderer never changes gameplay state. Named actions cross from input to app once. Mod loading and sharing feed normalized data to the same catalog boundary. The engine has no dependency on application, rendering, audio, or browser APIs.

Findings and concrete changes

1. Duplicate stage action paths

stage.js has overlapping keyboard, pointer, and handleIntent branches. Pause and Index behavior appears in multiple branches; the Index also has both a capture listener and document listener. This is the most likely source of one platform missing a new action or accepting it twice. Introduce one scene action dispatcher; have device adapters decode to named actions. Let renderers return named hit regions as the pointer contract. Verify a key, a tap, and a programmatic intent each cause exactly one equivalent action.

2. Mirrored and ambiguous runtime state

main.js owns game, while stage.js also stores board state, game handle, palette, glyphs, and render frame. setBoard() is an implicit synchronization point. Keep the engine snapshot authoritative and make the stage projection explicitly read-only. juice.js owns visual history, including facing, that can diverge during Undo or scene transitions; future mechanics must not add hidden state there. Refactor command lifecycle so move, wait, Undo, restart, and next level use the same projection refresh contract.

3. Engine complexity and cost

Rule parsing, push chains, automata, transformation, contacts, and settled event construction share one closure. Movement repeatedly filters the object array or finds IDs, and property queries scan rules repeatedly. Current tutorial boards are small, but larger mods and several automata will multiply these scans. Add turn-local maps for IDs, cells, and subject properties; rebuild at explicit mutation phases. Preserve the existing player → parse/transform → automata → parse/transform → contacts behavior and event ordering. A later change should split turn phases into named modules and specify simultaneous automata conflicts before changing their semantics.

4. Data and validation drift

Catalog, pack library, pack service, share, Gift, and Workshop repeat JSON clone helpers. A shared data-only clone helper should make ownership and mutability clear. Engine and mod loader also validate overlapping automata fields differently; that schema should be consolidated in a separate change before additional mechanics fields are accepted. Trusted local CommonJS executes via new Function; shared links must stay data-only. Any future community/remote executable modules need an explicit isolation model rather than reusing this trusted-local path.

5. Scaling contracts for future mechanics

Gates, switches, and one-step tiles introduce persistent state and turn-order questions. Every stateful mechanic must define: normalized authored data, collision/contact phase, deterministic conflict order, settled events, snapshot/restore representation, solver key, replay behavior, and Undo/reset behavior. The existing solver stores full snapshots in BFS nodes and keys only objects plus automata phase; its state codec will need to include any new mechanic counters or gate states. Do not add a mechanics field only to rendering or mod data.

Sequence and acceptance gates

  1. Current refactor: stage action dispatch, app session seam, engine lookup seam, shared data clone semantics, focused tests, and this architecture contract. Preserve public APIs and current level behavior.
  2. Presentation follow-up: extract stage scene renderers and layout/hit geometry; split board passes and juice event interpretation from animation timelines. Keep designer-owned visual behavior and mobile layout verified.
  3. Engine follow-up: version a mechanics snapshot envelope, centralize pack/engine validation, define automata collision policy, and introduce an effect interface for switches/gates/crumble. Extend solver state codec before shipping authored examples.
  4. Project hygiene: format and split minified CSS as a mechanical commit, separate map progress storage from drawing, add a supported solver command and CI authoring gate when the world-authoring OpenSpec change lands, and watch bundle size and import cycles.

Acceptance gates for each behavior-facing step: Node fixtures remain green; production build succeeds; desktop and mobile browser flows cover navigation, move/wait, rule changes, Undo, and imported CommonJS/URL data; screenshots and render_game_to_text() agree; no new console errors. Performance work should measure representative larger boards rather than assert timing from one device. No feature is complete if Undo, restore, replay, or solver identity omits its state.

Implemented in this pass

Boundary Result
Stage stage.js fell from 1,526 to 648 lines. HUD, Proof, Pause, and Win drawing live in focused render modules. Overlay keyboard, pointer, and programmatic actions share one dispatcher; pause actions have stable IDs separate from labels.
App Turn lifecycle, scene intent routing, and the developer canvas view moved out of main.js. Its line count is similar because the controller wiring is explicit; the dense gameplay and drawing branches are gone.
Engine Pure rule parsing and turn-local ID/cell/property indexes moved out of engine.js. An Undo browser regression exposed a stale index after restoring history; Undo now rebuilds the indexes.
Presentation board.js fell from 660 to 432 lines with a cached render model and entity pass. juice.js delegates visual tween projection and effect construction to pure helpers.
Data and structure Pack/share cloning has one data boundary. INDEX progress persistence is separate from map layout. Base and stage CSS are formatted for review.

The final architecture pass passed 183 Node tests, the production build, and browser flows for the built-in chapter, imported CommonJS packs, data-only sharing, Gift/Workshop, Win/next proof, mobile controls, automata WAIT, and death followed by Undo. The first browser run caught a missing stage renderer import; the second caught the stale engine index after Undo. Both were corrected and rerun. Screenshots of Proof, Win, mobile Gift, mobile automata, and restored MIAU were visually inspected, with no console errors in the final runs.

Remaining work is specific rather than a reason to keep expanding this refactor: stage pointer targets still use viewport fractions on mobile; juice.consume() remains a large event reducer; mod and engine automata validation overlap; the solver stores full BFS snapshots and needs a mechanics-aware state codec; automata conflict ordering is sequential. Those should be handled in separate changes with authored scenarios and regression fixtures before gates, switches, or crumble tiles join the default campaign.

The worlds team's inbox supplied concrete next gates. W8 was independently reproduced and fixed in c3537ca: SINK could remove the STOP word while settled rules still reported ROCK IS STOP, blocking the next move. The rule refresh change now reparses after contact removals and passed the exact reproduction, reset/Undo/restore fixtures, all seven Tidewater solver paths, and browser regression flows. Tidewater remains with the world team for an independent recheck before bundling. W11 unknown-predicate validation is next in the agreed content order, followed by W6 precedence and AND parsing and W3 solver throughput. Those require separate specs and differential fixtures. The createGame() API and snapshot shape remain compatible so the changes can land one at a time.

W6 scalability follow-up

W11 proof-local diagnostics and feature validation are implemented locally, with Gift and Workshop presentation awaiting designer review before the next public revision. W6 now has a strict OpenSpec contract for stacked-cell reading and contact/transform precedence. The parser enumerates every valid one-token-per-cell sentence path in spatial order. It has explicit path and span budgets and an indexed maximal-span pass; an over-budget board fails with a typed error instead of silently dropping rules. The CommonJS loader rejects an over-budget initial board with a structured proof error. During play, rule parsing is part of the turn transaction: an over-budget arrangement rejects the move and restores board, history, move count, indexes, and automata phase. Restore also preserves the prior settled game when a supplied snapshot exceeds the budget.

This closes a new combinatorial risk introduced by stacked alternatives. It does not replace W3's solver state codec: the current breadth-first solver still stores full snapshots and hit the 300,000-state check limit on two Crossword drafts after W6. The worlds team is rechecking those proofs and the Word Lab lesson whose old STOP puzzle now has a direct PUSH route.

Review boundary

The concurrent designer controls docs/design.md and docs/design.html; the world team controls docs/worlds/, mods/worlds/, and its OpenSpec change. This review does not rewrite those files. Design or level-authoring consequences should be sent through the existing inbox files. The current architecture work retains the public nzip URL and existing mod format.

Current follow-through

2026-09-23. The initial refactor is complete. The active work now has separate OpenSpec tracks, so engine semantics, content, and presentation can be accepted independently:

  1. D22 motion: the designer accepted Pause, Win, keycap press, clipped odometer, ledger stamp, and pushed-word skid. Title, INDEX, Gift, and Workshop now have scene entry/exit motion. The public release remains gated on the full browser scene/share flow and the designer's two captured P1 regressions: default-player chrome and the DEFEAT ledger color. src/stage/events.js owns DOM input wiring outside stage.js; input stays blocked during menu exit while a later external import can retarget its destination.
  2. W3 solver and W2 world handoff: the compact, fast solver includes object nouns and automata phases in its versioned state key and reuses authoritative turn phases. W3 is archived after a 23/23 sequential gate. Core proof 02 now teaches removing ROCK IS PUSH to clear a jam, verified by exact solver and browser replay. The worlds team subsequently reworked Tidewater shell-game; its 11-step solution and 623,612-state forbidden-rule check pass, and the complete 23-proof gate is being repeated on that revision.
  3. W6 content convergence: rule precedence and stacked parsing are implemented and tested. The worlds team owns the Crossword rework. An authoring mustForm claim requires an UNSOLVED forbidden-rule search; a LIMIT alone is inconclusive.
  4. Mechanic stressors: review the worlds team's channel-linked gate/plate and step-off FRAIL drafts as data contracts first. For each mechanic, require authored validation, phase order, complete snapshot/restore, compact state-key treatment, replay/Undo, settled events, and browser presentation before shipping a default proof. The designer chose a memoryless gate: after contacts, its open state derives from current plate occupancy or another thing occupying its cell, so the solver can derive it from object state rather than storing a hidden jam bit. A FRAIL leftover is represented by a noun change; a level-scoped map selects the next noun from the object's current noun. Confirm these formulas with differential scenarios and record final choices in focused OpenSpec changes before implementation.

The next structural cleanup should stay bounded: unify rendered hit-region geometry beyond Pause and Win, split the remaining juice.consume() event reducer by effect family, and consolidate overlapping mod/engine validation. Each slice needs a behavioral fixture tied to an observed risk; file length alone is not a success metric.

Next mechanic delivery sequence

The three active OpenSpec changes (add-declarative-mechanic-state-contract, add-channel-linked-gates, and add-frail-leftover-tiles) form one ordered delivery path after the public motion release:

  1. Add a validated, versioned mechanic declaration and lifecycle adapter outside engine.js. Preserve it through CommonJS import/export, data-only links, catalog cloning, Undo, reset, restore, and complexity rollback. Reject unsupported declarations before installation.
  2. Register mechanic state with the compact solver codec before adding a concrete mechanic. Two boards with identical entities but different mechanic scalars must get different keys, while no-mechanic levels keep their current routes.
  3. Implement channel plates/gates through shared movement blocker and after-contact projection hooks. Keep openness derived from the current board rather than a hidden bit; compare public and fast transitions, then replay the Crate Works gate scenarios.
  4. Implement FRAIL departures and the level-scoped noun map through the same lifecycle. Its future is carried by entity noun, existence, position, and the static map. Check handovers, word/automaton departures, SINK contacts, Undo, share, and solver replay against Tidewater fixtures.

Gate and FRAIL presentation and default-world bundling follow differential engine fixtures and designer review. This order keeps mechanic-specific branches out of the turn coordinator and preserves the single authoritative transition path.

World integration and state boundary update

2026-09-24. The first 23-proof gate and the revised seven-proof Crossword check passed on settled W6/W8 semantics. The authored world packs and bibles are tracked in Git, and manifest.rules: 2 now distinguishes the current rule semantics from legacy imported packs. D20 changes the runtime catalog shape: each pack owns a world and its own INDEX sheets, with packId/levelId progress and registration order as stable identity. The map model now represents these chapters; input, cards, and public bundling remain separate acceptance slices. Clockwork stays outside the default bundle until W12 custom-property presentation is implemented and reviewed.

The mechanic foundation was narrowed after the designer chose memoryless gates and stable-ID FRAIL leftovers. These are derived mechanics: gate openness, jams, and pressed plates recompute from settled objects and current nouns; FRAIL existence and noun live in state.objects, while leaves is static authored metadata. Persisting extra open, jammed, or crumbled bits would duplicate board truth and could diverge across Undo, restore, and solver replay. A host-owned lifecycle adapter still provides phase order, validation, and settled events. Only a future mechanic with real memory needs state.mechanics scalars and explicit compact-codec registration. The three active mechanic OpenSpec changes now state this split and require public/fast and restore parity without hidden bits.

The next integration gates are concrete: complete D20 world input/card behavior, validate optional world identity as a nonblocking note with a safe fallback, then bundle core, Crossword, Crate Works, and Tidewater in the approved order. Run the sequential world solver, full Node/build suite, and desktop/mobile Gift→INDEX→Proof→Win browser flow before updating the public alias. The older Garden Basics and Word Lab packs remain importable examples; Word Lab's W6 lesson copy is being corrected so its direct PUSH route matches its hint.

Post-D20 architecture status

2026-09-24. D20 passed designer review and is archived as 2026-09-24-add-world-index-presentation. Core, Crossword, Crate Works, and Tidewater are playable in that order at public revision 13. The complete sequential world gate solved 30 proofs, including the six Clockwork proofs that remain outside the default INDEX. The full 373-test Node suite, production build, local Gift/Workshop/share flow, and public desktop/mobile D20 flow passed without page errors.

The active proof now uses ${packId}:${localId} rather than a retained array position (50e4d68). The catalog keeps a direct ID map and invalidates derived pack summaries on mutation. In a 64-level local comparison, 2,000 last-level reads took 16.55 ms with the direct lookup and 1,079.7 ms via the old full-list clone and scan. Delayed Win behavior during pack replacement and removal remains a focused OpenSpec regression gate; its normal browser paths already pass.

The next engine seam is canonical level validation, now specified in consolidate-level-schema-boundary (73084e6). The direct engine and CommonJS loader currently disagree on automaton shorthand, phase bounds, ID casing, cue emptiness, and sound names. A single pure normalizer should feed direct games, trusted modules, and data-only shares, with caller-specific error adapters and parity fixtures. That work precedes new gates and FRAIL runtime behavior so their authored fields do not multiply validator drift.

Shared schema boundary and next mechanic seam

2026-09-24. 2950532 puts standalone engine levels, CommonJS packs, and data-only shared packs through src/level-schema.js. It canonicalizes object kinds, tokens, and automaton steps while retaining pack-only identity/size limits as a profile. Direct, module, and shared errors carry the same structural issue code/path in caller-specific envelopes. 4a8d371 rejects conflicting object fields and falsey explicit kinds so normalization cannot silently drop authored data. Catalog and Gift preview metadata cross a separate playableLevelFromView() adapter before game creation. The full 388-test Node suite, production build, D20 world-index browser flow, and Gift/Workshop/share browser flow pass without page errors. The sequential world solver also solved all 30 Core, Crossword, Crate Works, Tidewater, and Clockwork proofs with no issues or warnings.

The next bounded implementation is the accepted declarative mechanic foundation. Its registry must distinguish derived projections from truly stateful scalar data. Gate pressed, open, and jammed derive from current occupancy and nouns; FRAIL crumble derives from authoritative object movement, existence, noun, and a static level-scoped current-noun map. Neither may add a hidden solver bit. A stateful future mechanic must register every dynamic scalar in the codec and survive accepted/rejected turns, Undo, reset, restore, and public/fast parity. The level schema rejects undeclared mechanics and object-level leaves; it will admit host-supported declarations and gate channel metadata through the mechanic registry. The worlds team's six data-only gate and FRAIL fixtures are committed for differential replay.