Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Architecture Review (2026-08)

Architecture + tooling review — August 2026

Research sprint requested in NEXT.md after Season 2 took much longer to build than Season 1, and a single afternoon of post-playthrough bugfixes felt disproportionately slow. Four questions, each researched independently (parallel agents, read-only — no redesign started). This doc is the findings + a proposal to discuss before any of it gets implemented.

Bottom line: the four questions converge on one theme — push logic and data out of main.ts’s god-object/god-function shape into small, explicit, independently-testable pieces. Not one big rewrite; the same move applied four ways.


1. The Season 1/2 split — structurally wrong, not just oversized

Today’s mechanism (per MECHANICS.md): most levels reuse their exact Season 1 LevelData object for Season 2; escalations splice in via optional season2Extra* fields, read by loadLevelData()/resetHazards() in main.ts only when season === 2. Tanzstunde is the one exception, with its own fully distinct LevelData.

Inventory: 16 distinct runtime branch points in main.ts, spanning 8 unrelated concerns, all keyed off one mutable season: 1 | 2 variable:

ConcernSites
Hazard/platform splice from season2Extra*1 (bundles platforms + hazards + duty-cycle carving)
Item splice from season2ExtraItems1
Wind-push physics + telegraph render2
Hazard-reveal difficulty tuning (global constant, applies even to levels with no hazards)1
Equipment-gated scoring (footballGoal), duplicated at kick-landing and pickup2
Music layer selection1
Portal shared-state bugfix (used = false reset loop)1
Game-completion/unlock gating1
State-machine progression (season assignment, S1→S2 trigger)3
Ending-screen UI (montage branch, label text, S1-only prompt)3

Plus: the LEVELS2 array reuses the same object references as LEVELS for 4 of 5 levels — the root cause of the PortalDef.used bug (finding a portal in Season 1 permanently marked it used in Season 2, since both seasons shared one object).

Verdict: three orthogonal things are conflated under one flag — which data to load, equipment-gating rules that are really per-item not per-season, and meta/UI state. The codebase already shows it knows better in places: dressCodeGuard gates cleanly on player.hasTailoredClothes alone, no season check at all — while the structurally identical footballGoal gate redundantly re-checks season === 2 && !player.hasSoccerShoes at two separate sites, because the data model isn’t trusted. HAZARD_REVEAL_DISTANCE_S2 is a global per-season constant even though only Minigolf has hazards at all. MECHANICS.md’s own warning (“never edit season2Extra* without checking every read site, or Season 1 regresses silently”) is a description of fragility, not a safe convention.

Recommendation — explicit per-level Season 2 LevelData (the pattern Tanzstunde already proves out). Give each shared level its own *_s2.ts file that spreads Season 1 and overrides arrays unconditionally:

export const MINIGOLF_S2: LevelData = {
  ...MINIGOLF,
  hazards: [...MINIGOLF.hazards!, ...S2_DUTY_CYCLE_HAZARDS],
  items: MINIGOLF.items.filter(notAmbushCameo).concat(S2_ITEMS),
  windZones: [],
  portal: MINIGOLF.portal && { ...MINIGOLF.portal, used: false },  // own object, no aliasing
};

loadLevelData()/resetHazards() then read level.hazards/items/windZones unconditionally — no season === 2 branch for any of them. This removes 10 of the 16 sites outright. The remaining ~6 (music lookup, completion gate, S1→S2 trigger, ending UI) are genuinely cross-cutting meta concerns — fine as small table-driven checks, same pattern musicForLevel() already uses well.

Not recommended: a generic engine-native “level variant” abstraction (speculative generality for a Season 3 that doesn’t exist as a game structure yet — see §2), or stopping at “just add more helper functions” (treats the symptom, not the conflated data model).

Added to scope 2026-08-08, now implemented: a real numeric-tunables model. The per-level LevelData-per-season refactor above fixes every case where what varies is content (hazards/items/platforms/wind zones/music layers — all dissolve into per-season LevelData fields). It didn’t cover the one case where what varies is a bare numeric constant with no per-level home — HAZARD_REVEAL_DISTANCE_S1 = 130 / HAZARD_REVEAL_DISTANCE_S2 = 85, selected by a season === 2 ? ... : ... ternary that doesn’t extend to a third season. Full writeup, including the classification pass that scoped this precisely — only genuinely global, cross-level tunables get a Season recs-component, everything else stays level content — moved to its own doc: TUNABLES-DESIGN-2026-08.md. Implemented in src/game/season.ts: Season is a real recs entity, HazardRevealDistance is a component on it, same API the equipment refactor already uses on Player. Verified: tsc/vitest clean, both Playwright suites re-verified on trullala.

Scope this alongside the per-level refactor above (both are the same “push season-variance out of scattered main.ts control flow into explicit data” move, just for two different kinds of variance) — not a separate follow-up pass.

On PortalDef.used specifically: the per-level refactor fixes this instance (S2 levels become distinct objects, no more cross-season aliasing) but not the underlying category — mutable one-shot state living on a template object at all. The closing move, independent of the bigger refactor: move used-style flags out of LevelData/ PortalDef into per-playthrough runtime state (e.g. a usedPortals: Set<string> alongside solids/hazards), exactly how resetHazards() already treats hazards as rebuilt-fresh runtime state rather than template mutation.

Cost: real multi-hour effort across the 4 remaining shared levels (Minigolf, Lidl Lunch, Soccer, Clouds) plus main.ts’s load/reset functions — restructuring, not new design, since the escalation content is already fully spelled out in existing season2Extra* comments. tools/smoke_test_season2.py should be unaffected (keys off ?season=2 and an array of LevelData) but should be re-run to confirm the portal fix holds.


2. MUD / OPCraft — skip the ECS rewrite, borrow two ideas

Cloned and read latticexyz/mud (the recs package: entities as opaque IDs, plain-data components, systems as RxJS subscriptions over queries) and OPCraft (Lattice’s own voxel-game demo built on it).

Blockchain-specific cruft, confirmed irrelevant: store, world, world-modules, store-indexer, store-sync, paymaster, entrykit, faucet, gas-report, all of OPCraft’s packages/contracts/, and the on-chain sync machinery inside the network layer (setupNetwork.ts, wallet/publicClient wiring).

Verdict: not worth a core rewrite. pixelsp33d’s actual scale — 5 levels, ~6-8 entity kinds (ItemInstance, HazardDef, PortalDef, WindZone, Player) — is well below where MUD’s reactive-ECS machinery (RxJS streams, typed-array component stores, enter/exit query diffing) pays for itself. Rewriting main.ts’s loop, player.ts’s physics, and level.ts’s data shapes into query-and-subscription style would be real cost for a game this small.

What’s directly transferable, narrowly:

  • Component-as-plain-data-keyed-by-entity, applied just to the Season-variant problem. Instead of season2Extra* fields plus scattered if (season === 2) branches, a lightweight per-entity extras bag (e.g. Map<ItemInstance, { windPush?: number }>) gets the same “Season 3 adds a field without touching main.ts’s control flow” property MUD’s ECS gives, at a fraction of the machinery. Worth considering if/when Season 3 happens — not urgent now, and largely superseded by the §1 refactor for the two seasons that actually exist today.

  • A generic live-state debug panel, replacing the ad-hoc window.__ps33dDebug hook. MUD’s dev-tools package mounts a live table of every component’s value for every entity — conceptually what __ps33dDebug does with a fixed hand-picked field list (x/levelId/sneaking/isHurt/grounded/energy/speedTier/state).

  • Equipment-as-a-capability-set on Player, added 2026-08-08 (Torsten specifically flagged liking this part of MUD’s model). Today player.ts has four separate booleans and four near-identical methods:

    hasKnight = false; hasSoccerShoes = false; hasDanceShoes = false; hasTailoredClothes = false;
    collectPowerup(): void { this.hasKnight = true; this.itemsCollected++; }
    collectSoccerShoes(): void { this.hasSoccerShoes = true; this.itemsCollected++; }
    // ...same shape, twice more
    

    This is the same “fixed field per case, checked at N call sites” pattern as the season branches — just smaller (4 cases, not 16). In MUD’s model, a capability is just membership in an entity’s component set, checked uniformly by any system, no per-capability boilerplate. Concretely: player.equipment: Set<Equipment> (a literal union type, not stringly-typed) with one equip(item: Equipment) method replaces all four fields/methods, and every gate site (dressCodeGuard, footballGoal, the dance-sequence check) reads player.equipment.has('tailoredClothes') instead of a dedicated field. New equipment (Season 3 or otherwise) becomes “add a union member,” not “add a field + a method + remember every site that should check it.”

    Honest caveat: at exactly 4 kinds today the explicit booleans are still perfectly readable on their own — this isn’t urgent in isolation. It’s worth doing because it’s cheap and touches the same call sites the §1 refactor is already touching (equipment gates and season gates are read from overlapping code), not because 4 booleans are a real problem by themselves. Worth stealing as a pattern (introspect Player + current level’s item/hazard arrays generically), not as code — no need for MUD’s React/zustand stack.

Crossover relevant to §4: recs ships pure unit tests (Component.spec.ts, System.spec.ts, World.spec.ts) with zero rendering — “create a world, run systems, assert on component values.” Confirms the shape of what a Node-testable pixelsp33d suite should look like.


3. Canvas2D — no problem today; the real latent issue is missing culling

Traced startLoop() → one render() per requestAnimationFrame, no sub-stepping. Estimated draw calls for Tanzstunde S2 (the level named as the likely worst case):

PassCalls
drawGround (7 pillar segments tiled at 32px + 3 platforms)~61
drawItems (25 items × sprite + 2-line drop-shadow label)~75
drawDecorations (6 shimmering water tiles)6
Parallax background ×22
Boss + player2
HUD~12
clearRect1
Total~155–165/frame

No shadowBlur/gradients anywhere in the codebase (confirmed by grep) — just hue-rotate filters and globalAlpha, both cheap compositor ops. This is trivial load for Canvas2D even on Android TV’s TV Bro (Chromium-based); the brief’s premise of a stacked-effects hotspot at the “finale” doesn’t actually exist in code.

The real finding: none of drawGround/drawItems/drawDecorations cull by camera position — every pillar, item, and decoration in the entire level draws every frame regardless of what’s on-screen. Cost scales with level size, not viewport contents; currently masked by small levels (≤1900px, ≤25 items), not actually bounded.

Decision framework — revisit only if:

  1. Per-frame draw calls exceed ~2,000 (10–20× current) — e.g. a much larger level, or a future season stacking genuine per-entity shader-like effects (real shadowBlur, per-sprite gradients) on dozens of entities at once.
  2. Dropped frames are measured on Android TV specifically (the weakest real target) — a harder signal than any static count.
  3. Level width/item density grows enough that the missing culling becomes the actual bottleneck on its own merits.
  4. Mobile touch-control input adds measurable latency stacked on current frame cost.

If a threshold is crossed: add viewport culling first — cheap, targeted, no new dependency, skip drawing anything whose bounds don’t intersect [camera.x, camera.x + VIEW_W]. Only if draw volume is still the bottleneck after culling would a rendering-layer change be justified, and the specific next step would be a thin hand-rolled WebGL sprite-batching layer, not PixiJS — the actual need is “batch many identical small sprites with translate/flip,” nothing PixiJS’s scene-graph/ filter/interaction-manager machinery adds value for, and a hand-rolled batcher keeps the project’s deliberate “no engine” architecture intact instead of pulling in a ~300KB dependency with its own render-loop opinions.


4. Testing — make Playwright secondary, migrate mechanic-by-mechanic

What real game studios do, distinct from web E2E:

  1. Deterministic simulation / headless mode — game logic runs with a fixed timestep, zero rendering.
  2. Replay-based testing — record real input sequences as data, replay against the simulation, assert on resulting state.
  3. Unit-testing pure logic in isolation — collision math, hazard state machines, scoring functions.
  4. Snapshot/property-based testing on level data — e.g. “every jump-required gap must be ≤ max jump range,” checkable at level-authoring time. Would have caught the real Dance Shoes platform bug found this session via manual playtesting instead.

Playwright is genuinely load-bearing only for a fifth tier — real input→rendering integration smoke checks — not the primary verification method.

pixelsp33d’s actual entanglement:

  • src/engine/collision.ts (overlaps, moveAndCollide) is already pure — plain data in, data out, zero DOM. Node-testable today, no changes needed.
  • src/main.ts is the blocker: document.getElementById('game') runs at module top level, so importing main.ts anywhere throws outside a browser. Genuinely pure functions are trapped inside it — dutyCycleWantsOpen/dutyCycleTimeUntilFlip (hazard duty-cycle math) and carveGap (level-geometry math) — none touch DOM, but need to move to their own module (e.g. src/game/hazards.ts) before Node can import them.
  • Player.update() takes an Input whose constructor calls window.addEventListener, and Input’s private fields block duck-typing a plain mock in its place — testing Player.update() end-to-end needs either a DOM shim or Input refactored to an interface. The item-pickup and dance-grading logic in main.ts closures over playSfx, levelTime, item.collected mutation — extractable, but each is its own small refactor.

Proof-of-concept built (uncommitted, for review):

  • src/game/player.ts — extracted maxJumpGapPx(moveSpeed): number, a pure function computing the jump envelope from the module’s existing GRAVITY/JUMP_VELOCITY constants (reuses real physics, can’t drift from it). This is exactly the “jump gap ≤ max jump range” check from above.
  • src/game/player.test.ts (new) — 3 Vitest assertions (speed scaling, a hand-derived value check, the shape of a “gap too wide” invariant).
  • package.json/package-lock.json — added vitest as a devDependency + a test:unit script.
  • Ran clean: npx vitest run → 3/3 passed in 217ms, zero browser. npx tsc --noEmit still passes.
  • Git status: M package-lock.json, M package.json, M src/game/player.ts, ?? src/game/player.test.ts — left uncommitted, Torsten’s call to accept/tweak/discard.

Recommendation: yes, make Playwright secondary. Migration order:

  1. collision.ts is free — write tests today.
  2. Pull dutyCycleWantsOpen/dutyCycleTimeUntilFlip/carveGap out of main.ts into src/game/hazards.ts, test those. (This is the same extraction the §1 refactor already needs — doing them together avoids touching the same code twice.)
  3. Refactor Input to an interface so Player.update() can be tested with a plain mock object.
  4. Tackle the item-pickup/dance-grading closures in main.ts last, extracting pure “resolve this pickup” functions that take state and return a delta rather than mutating in place.

Keep the two existing Playwright suites as the final end-to-end smoke layer, not the only tool.

Fleet offload: worth doing, but its value shrinks in proportion to how much of this migration happens. If the pure-Node suite becomes primary, Playwright shrinks to a handful of smoke checks — cheap enough to run locally, so a homelab box mainly saves “competing with the dev server on the same laptop” rather than solving a scaling problem. Recommend doing it opportunistically (a spare box like tra/trullala running the existing two smoke suites on a schedule) rather than as a prerequisite to anything above.


Proposed order of work

  1. Season-split refactor (§1) — done, all three pieces merged to main: the equipment-as-capability-set change (§2 addendum — real @latticexyz/recs components on Player), the season-tunables model (src/game/season.ts, see TUNABLES-DESIGN-2026-08.mdSeason as a recs entity, HazardRevealDistance as its one real component today), and the per-level LevelData-per-season content restructure itself (minigolf_s2.ts/lidl_lunch_s2.ts/soccer_s2.ts joining clouds_s2.ts/tanzstunde_s2.ts, replacing season2Extra* splicing entirely — main.ts now reads level.hazards/items/platforms/windZones unconditionally).
  2. Testing migration (§4) — partially done: maxJumpGapPx(), season.ts’s hazardRevealDistanceFor(), and a few other pure functions have real colocated Vitest coverage now, and both smoke suites got real new/extended cases throughout this session’s work. The broader “extract every pure function out of main.ts’s giant loop” campaign is not fully done — still genuinely ongoing, not urgent.
  3. Debug-panel generalization + extras-bag pattern for future season-variant data (§2) — small, whenever convenient, not urgent. Not built.
  4. Fleet offload for the Playwright suites (§4) — done. tools/fleet_test.sh + tools/remote_test_runner.sh exist and were used repeatedly this session to verify work on trullala via the shared trullala-tasks tmux session, per tracker’s fleet convention.
  5. Canvas2D / viewport culling (§3) — shelved. No action needed unless one of the four listed thresholds is actually crossed.

Status as of 2026-08-08: item 1 (season-split refactor, all three pieces) and item 4 (fleet offload) are done and merged. Item 2 is partial. Items 3 and 5 are untouched by design (not urgent / shelved).