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:
| Concern | Sites |
|---|---|
Hazard/platform splice from season2Extra* | 1 (bundles platforms + hazards + duty-cycle carving) |
Item splice from season2ExtraItems | 1 |
| Wind-push physics + telegraph render | 2 |
| Hazard-reveal difficulty tuning (global constant, applies even to levels with no hazards) | 1 |
| Equipment-gated scoring (footballGoal), duplicated at kick-landing and pickup | 2 |
| Music layer selection | 1 |
Portal shared-state bugfix (used = false reset loop) | 1 |
| Game-completion/unlock gating | 1 |
| 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 scatteredif (season === 2)branches, a lightweight per-entityextrasbag (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.__ps33dDebughook. MUD’sdev-toolspackage mounts a live table of every component’s value for every entity — conceptually what__ps33dDebugdoes 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). Todayplayer.tshas 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 moreThis 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 oneequip(item: Equipment)method replaces all four fields/methods, and every gate site (dressCodeGuard,footballGoal, the dance-sequence check) readsplayer.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):
| Pass | Calls |
|---|---|
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 ×2 | 2 |
| Boss + player | 2 |
| HUD | ~12 |
| clearRect | 1 |
| 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:
- 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. - Dropped frames are measured on Android TV specifically (the weakest real target) — a harder signal than any static count.
- Level width/item density grows enough that the missing culling becomes the actual bottleneck on its own merits.
- 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:
- Deterministic simulation / headless mode — game logic runs with a fixed timestep, zero rendering.
- Replay-based testing — record real input sequences as data, replay against the simulation, assert on resulting state.
- Unit-testing pure logic in isolation — collision math, hazard state machines, scoring functions.
- 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.tsis the blocker:document.getElementById('game')runs at module top level, so importingmain.tsanywhere throws outside a browser. Genuinely pure functions are trapped inside it —dutyCycleWantsOpen/dutyCycleTimeUntilFlip(hazard duty-cycle math) andcarveGap(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 anInputwhose constructor callswindow.addEventListener, andInput’s private fields block duck-typing a plain mock in its place — testingPlayer.update()end-to-end needs either a DOM shim orInputrefactored to an interface. The item-pickup and dance-grading logic inmain.tsclosures overplaySfx,levelTime,item.collectedmutation — extractable, but each is its own small refactor.
Proof-of-concept built (uncommitted, for review):
src/game/player.ts— extractedmaxJumpGapPx(moveSpeed): number, a pure function computing the jump envelope from the module’s existingGRAVITY/JUMP_VELOCITYconstants (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— addedvitestas a devDependency + atest:unitscript.- Ran clean:
npx vitest run→ 3/3 passed in 217ms, zero browser.npx tsc --noEmitstill 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:
collision.tsis free — write tests today.- Pull
dutyCycleWantsOpen/dutyCycleTimeUntilFlip/carveGapout ofmain.tsintosrc/game/hazards.ts, test those. (This is the same extraction the §1 refactor already needs — doing them together avoids touching the same code twice.) - Refactor
Inputto an interface soPlayer.update()can be tested with a plain mock object. - Tackle the item-pickup/dance-grading closures in
main.tslast, 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
- Season-split refactor (§1) — done, all three pieces merged to
main: the equipment-as-capability-set change (§2 addendum — real@latticexyz/recscomponents onPlayer), the season-tunables model (src/game/season.ts, seeTUNABLES-DESIGN-2026-08.md—Seasonas a recs entity,HazardRevealDistanceas its one real component today), and the per-levelLevelData-per-season content restructure itself (minigolf_s2.ts/lidl_lunch_s2.ts/soccer_s2.tsjoiningclouds_s2.ts/tanzstunde_s2.ts, replacingseason2Extra*splicing entirely —main.tsnow readslevel.hazards/items/platforms/windZonesunconditionally). - Testing migration (§4) — partially done:
maxJumpGapPx(),season.ts’shazardRevealDistanceFor(), 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 ofmain.ts’s giant loop” campaign is not fully done — still genuinely ongoing, not urgent. - Debug-panel generalization +
extras-bag pattern for future season-variant data (§2) — small, whenever convenient, not urgent. Not built. - Fleet offload for the Playwright suites (§4) — done.
tools/fleet_test.sh+tools/remote_test_runner.shexist and were used repeatedly this session to verify work ontrullalavia the sharedtrullala-taskstmux session, per tracker’s fleet convention. - 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).