Mechanics
How the game actually works, right now — a quick-reference map, not the
history of how it got this way (that’s PLAN.md) or what’s coming next
(that’s NEXT.md).
Architecture
src/main.ts— the whole game loop (update()/render()), the state machine, and most gameplay logic (hazard/item collision, camera, transitions). By far the largest file; most features touch it.src/game/player.ts— thePlayerclass: position/physics, energy, speed tier,takeHit()/respawn(). Equipment (hasKnight/hasSoccerShoes/hasDanceShoes/hasTailoredClothes) is not plain booleans — each is a getter backed by a real@latticexyz/recscomponent on the player’s own entity (equipmentWorld,createEntity/defineComponent/hasComponent/setComponent). Reads (player.hasX) and writes (player.collectX()) look identical to plain fields from every other file’s perspective; onlyplayer.tsitself touches recs directly. SeeARCHITECTURE-REVIEW-2026-08.md§2 for why.src/game/season.ts—Seasonis also a real recs entity (its own world,seasonsWorld), but scoped narrowly: only genuinely global, cross-level numeric tunables live here (today:hazardRevealDistanceFor(season), replacing aseason === 2 ? ... : ...constant-pair ternary). Level-scoped content variance does not belong here — see “Season 1 vs. Season 2” below.TUNABLES-DESIGN-2026-08.mdhas the full classification/reasoning for what does and doesn’t belong in this module.src/game/level.ts—LevelData/ItemInstance/HazardDef/PortalDef/WindZonetype shapes shared by every level file.src/game/items.ts— theItemDefcatalog (every collectible/obstacle’s sprite, size,kind, and optional per-obstacleknockback/hurtDuration/invulnDurationoverrides) andItemKind(food/speed/obstacle/powerup/equipment/timer).src/game/levels/*.ts— one file per level/sub-level, each aLevelDataobject built withcreateLevelHelpers()(helpers.ts)’s placement helpers (onGround,onTop,patrol,bob,waltz).src/engine/— reusable primitives with no game-specific knowledge:input.ts(keyboard/gamepad, held vs. edge-triggered methods),audio.ts(SFX/music/layers/ reverb),collision.ts,camera.ts,parallax.ts.src/game/scoreboard.ts—localStorage-backed best-score tracking, no backend.tools/*.py— asset generation (sprites, tiles, music) — everything is generated, nothing is a recorded/downloaded sample. Run these to regenerate assets after editing a palette/shape/note sequence; they overwriteassets/gen/.tools/smoke_test.py/tools/smoke_test_season2.py— Playwright-based regression suites (Season 1 / Season 2). Require the dev server running. See “Testing in CLAUDE.md” for how to run them.
The state machine (main.ts)
state is one of: 'title' | 'chooser' | 'playing' | 'won' | 'gameComplete' | 'transition' | 'dancing'.
title→ jump/confirm →chooser(pick a player name) →playing.playingis the main gameplay loop; touching a level’s boss/exit flips towon(mid-game) or, on the last level,gameComplete(end of a season).transitionis the Underworld enter/exit vignette (a circular wipe, not an instant cut) — seestartTransition()/enterUnderworld()/exitUnderworld().dancingis the key-press dance-sequence mechanic — used in two different places/modes, not a single one-shot QTE:mode:'teach'(Tanzstunde S2’s Underworld sub-level,underworld_tanzstunde.ts) andmode:'recall'(Clouds S2’s Upperworld,upperworld_clouds.ts), both matching against the same fixedDANCE_COMBO. Pauses normal hazard/item updates while active (updateDanceSequence()). Teach mode has twophases:'demo'(thedancePartnerNPC performs the combo once, no input read, bannerWATCH!) then'attempt'(interactive, loops on any miss untilDANCE_REQUIRED_CLEAN_PASSESclean passes land — currently 1 — instead of resolving after a single ungraded pass); recall mode isphase:'attempt'only, single-shot per portal visit but retriable (no “triggered once” flag, checked fresh every frame like the teach re-trigger). SeePLAYTEST-FINDINGS.md‘s PF-3 for the full history.danceSequenceStep()(player.ts) costs 5 energy and grants a flat 10danceCreditsper perfect/good step (tracked separately fromitemsCollected, whichcomputeScoreweights at +100/item) — teach’s loop-on-any-miss and recall’s re-enterable portal both made this farmable for free before PF-18’s fix. ADANCE_SESSION_TIMEOUT_S(5 minutes real — a deliberately generous stopgap while its original anti-farm rationale gets rethought now that the portal itself is re-enterable (PF-20) — overridable via?danceTimeoutS=for tests) caps how long any one visit can run, shown as a live countdown (centered under the beat bar in the dance overlay, red under 10s like the level-timer HUD). On timeout, aDANCE_TIMEOUT_HOLD_S(0.8s) hold flashes “TIME’S UP!” and plays thetime_outSFX (PF-19), thenabandonDanceSequence()ejects the player back to the room before the Underworld/Upperworld viaexitUnderworld(), same as a normal portal exit, doubling as an escape hatch for a stuck/AFK player. Purely a discard, not a penalty:teachCompleted/recallSucceededare only ever set insidefinishDanceSequence(), which a timeout never reaches — a mid-lesson timeout just loses that visit’s progress (nothing was learned yet), and a mid-recall timeout means no chest/rickroll trigger, same as any other unfinished attempt. Both physical portals are genuinely re-enterable within a playthrough (PF-20):PortalDef.usedis a shared one-shot gate that Soccer/Lidl Lunch’s chest portals need (a real farmable one-off item), but Tanzstunde’s/Clouds’ portals opt out viaoneShot: false, andexitUnderworld()resetsusedback tofalseon the way out whenever the portal it’s restoring isn’t one-shot.exitUnderworld()places the ejected player just outsidetrigger’s edge —PortalDef.exitSidepicks which one (default'left'; Tanzstunde’s is'right', PF-22, since the default left-side spot overlapped a waltzingdancePartner’s sway).gameCompleteon Season 2 (not the Season 1 checkpoint) is the true ending — it unlocks the title-screen attract mode (hasCompletedGame, persisted vialocalStorage) and shows a crossfading montage of every level’s own background (drawBackdropSequence()), reused by the attract mode and by?demo=backdrops.
Season 1 vs. Season 2
Every level has its own explicit Season 2 LevelData object (*_s2.ts, per
ARCHITECTURE-REVIEW-2026-08.md §1) — each spreads its Season 1 counterpart and
overrides hazards/items/platforms/windZones/portal with that level’s Season 2
escalations. loadLevelData()/resetHazards() in main.ts read
level.hazards/items/platforms/windZones unconditionally — no season === 2
branch for level content. LEVELS2 (main.ts) simply points at the five *_S2 objects
instead of the Season 1 ones; there is no splicing at load/respawn time and no
season-gated read site to keep in sync.
Each *_S2 object also gets its own portal ({ ...S1.portal, used: false }, not a
shared reference) — this is what fixes the PortalDef.used aliasing bug: Season 1 and
Season 2 no longer share one mutable PortalDef, so finding a portal in one season can
never mark it “used” in the other. That per-season object is still a module-level
singleton, though — used persisted across a whole page session regardless (PF-17),
including into a second playthrough on the same page load, until quitToTitle()
started explicitly resetting every portal’s used flag (resetAllPortals()).
The remaining season === 2 checks in main.ts are genuinely cross-cutting meta
concerns, not level content, and stay as small table-driven/inline checks: music-layer
selection (SEASON2_LAYERS), the game-completion gate, equipment-gated scoring
(footballGoal/hasSoccerShoes, duplicated at kick-landing and pickup), and the
ending-screen montage/label.
Secret rooms (Underworld / Upperworld)
A PortalDef on any LevelData is a generic secret-entrance mechanism — trigger is a
plain Rect overlap check with no dependency on continuous ground (confirmed: a
trigger can sit on a floating platform, or at the world ceiling for a flight-only
approach). Five real destinations exist: two lava-cave “Underworld” rooms
(underworld_river_cola.ts/underworld_mushroom_house.ts, reached via Lidl
Lunch/Soccer), a “backstage door” Underworld room for the Tanzstunde dance lesson
(underworld_tanzstunde.ts), and “The Upperworld” for Clouds’ dance recall
(upperworld_clouds.ts, deliberately different in-fiction name from the others — see
that file’s own top comment). Internally all five still use the same
enterUnderworld()/exitUnderworld() machinery and id.startsWith('underworld_')
convention (reverb, music lookup) regardless of player-facing name.
Portals vary in whether they’re a genuine optional secret or effectively mandatory —
check each one’s own comments; requiresSneak and off-the-main-path elevation
(platform jump, or the world ceiling reachable only by sustained flight) are both used
as “this is a deliberate detour” signals, not just visibility.
Dress-code guard (Tanzstunde S2)
A genuine hard block (dressCodeGuard, added to moveSolids — real wall collision,
not a damage-on-touch hazard) gated on player.hasTailoredClothes. Tailored Clothes
exists only via Soccer’s own portal secret, with two independent chances (Season 1 and
Season 2 Soccer both spawn a fresh copy). Without it, the guard is bypassable by a
deliberate running jump off the Dance Shoes platform — WALK_SPEED fails,
RUN_BASE clears with real margin (see PLAYTEST-FINDINGS.md PF-4 for the exact
numbers) — a real, telegraphed (takeoff_marker.png) skill path, not an accident, kept
specifically so a player who misses Tailored Clothes both times still has a legitimate
way through.
Recurring ambush cameos
Four “harmless Season 1 decoration turns fatal on a Season 2 replay” obstacles
(kn00tFreezerAmbush/kn00tCloudsAmbush/kn00tMinigolfAmbush/kn00tSoccerAmbush) are
not one-shot — each re-arms once the player is both far enough away
(AMBUSH_REARM_DISTANCE_PX) and enough time has passed since the last hit
(AMBUSH_REARM_TIME_S), checked live every frame via a per-item ambushHitTimer/
ambushArmed state (not levelTime, which resets on every respawn — see the in-code
comment on the ambush-hit branch for why that matters). This replaced an earlier
permanent collected = true that was itself a deliberate fix for a real softlock (see
PLAYTEST-FINDINGS.md PF-16) — the re-arm gating exists specifically so the recurring
danger can’t chain-death a player who just respawned near it.
Debug URL params (dev/test only, not for real play)
All read once at startup in main(), in src/main.ts:
| Param | Effect |
|---|---|
?level=N | Start at level index N instead of 0 |
?season=2 | Start directly in Season 2 (skips needing a full Season 1 clear) |
?spawnX=N | Override the first level load’s spawn X — one-shot, cleared after first use so it can’t leak into a later enterUnderworld() call and misplace the player in a much narrower sub-level |
?equip=soccerShoes,danceShoes,tailoredClothes | Grant Season 2 equipment without the real pickup detour (comma-separated) |
?demo=backdrops | Standalone showcase: cycles through all 8 backdrops, no HUD, no input, loops forever |
?attract=1 | Force the title-screen attract-mode backdrop montage on, without needing to actually finish the game first |
?resetScoreboard=1 | Wipe the persisted high-score table immediately on load, no confirmation prompt. A scripted/CLI-only alternative to the OSD “Restart Game” button (which has its own in-OSD confirm panel now — see README.md’s “Resetting the scoreboard” section). |
?danceTimeoutS=N | Override DANCE_SESSION_TIMEOUT_S (default 300) — shrinks the Tanzstunde/Upperworld dance-session escape-hatch timeout so a test can trigger it without a real 5-minute wait |
window.__ps33dDebug (set every frame in update()) exposes live state for Playwright
tests — grown considerably as new mechanics needed scriptable verification without
screenshot-diffing. Core: x, levelId, sneaking, isHurt, grounded, energy,
speedTier, state. Equipment: hasKnight/hasSoccerShoes/hasDanceShoes/
hasTailoredClothes. Finale/dance mechanics: recallSucceeded,
rickrollStubTriggered, chestsFound, teachCompleted, teachCleanPasses,
dancePhase, danceMode, danceCleanPasses, danceLearnedCount, danceGradesLength,
danceCredits, danceSessionElapsed.
Read the actual __ps33dDebug assignment in main.ts for the authoritative current
list rather than trusting this doc going stale again.