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

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 — the Player class: 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/recs component 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; only player.ts itself touches recs directly. See ARCHITECTURE-REVIEW-2026-08.md §2 for why.
  • src/game/season.tsSeason is 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 a season === 2 ? ... : ... constant-pair ternary). Level-scoped content variance does not belong here — see “Season 1 vs. Season 2” below. TUNABLES-DESIGN-2026-08.md has the full classification/reasoning for what does and doesn’t belong in this module.
  • src/game/level.tsLevelData/ItemInstance/HazardDef/PortalDef/WindZone type shapes shared by every level file.
  • src/game/items.ts — the ItemDef catalog (every collectible/obstacle’s sprite, size, kind, and optional per-obstacle knockback/hurtDuration/invulnDuration overrides) and ItemKind (food/speed/obstacle/powerup/equipment/timer).
  • src/game/levels/*.ts — one file per level/sub-level, each a LevelData object built with createLevelHelpers() (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.tslocalStorage-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 overwrite assets/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.
  • playing is the main gameplay loop; touching a level’s boss/exit flips to won (mid-game) or, on the last level, gameComplete (end of a season).
  • transition is the Underworld enter/exit vignette (a circular wipe, not an instant cut) — see startTransition()/enterUnderworld()/exitUnderworld().
  • dancing is 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) and mode:'recall' (Clouds S2’s Upperworld, upperworld_clouds.ts), both matching against the same fixed DANCE_COMBO. Pauses normal hazard/item updates while active (updateDanceSequence()). Teach mode has two phases: 'demo' (the dancePartner NPC performs the combo once, no input read, banner WATCH!) then 'attempt' (interactive, loops on any miss until DANCE_REQUIRED_CLEAN_PASSES clean passes land — currently 1 — instead of resolving after a single ungraded pass); recall mode is phase:'attempt' only, single-shot per portal visit but retriable (no “triggered once” flag, checked fresh every frame like the teach re-trigger). See PLAYTEST-FINDINGS.md‘s PF-3 for the full history. danceSequenceStep() (player.ts) costs 5 energy and grants a flat 10 danceCredits per perfect/good step (tracked separately from itemsCollected, which computeScore weights 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. A DANCE_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, a DANCE_TIMEOUT_HOLD_S (0.8s) hold flashes “TIME’S UP!” and plays the time_out SFX (PF-19), then abandonDanceSequence() ejects the player back to the room before the Underworld/Upperworld via exitUnderworld(), same as a normal portal exit, doubling as an escape hatch for a stuck/AFK player. Purely a discard, not a penalty: teachCompleted/recallSucceeded are only ever set inside finishDanceSequence(), 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.used is 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 via oneShot: false, and exitUnderworld() resets used back to false on the way out whenever the portal it’s restoring isn’t one-shot. exitUnderworld() places the ejected player just outside trigger’s edge — PortalDef.exitSide picks which one (default 'left'; Tanzstunde’s is 'right', PF-22, since the default left-side spot overlapped a waltzing dancePartner’s sway).
  • gameComplete on Season 2 (not the Season 1 checkpoint) is the true ending — it unlocks the title-screen attract mode (hasCompletedGame, persisted via localStorage) 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:

ParamEffect
?level=NStart at level index N instead of 0
?season=2Start directly in Season 2 (skips needing a full Season 1 clear)
?spawnX=NOverride 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,tailoredClothesGrant Season 2 equipment without the real pickup detour (comma-separated)
?demo=backdropsStandalone showcase: cycles through all 8 backdrops, no HUD, no input, loops forever
?attract=1Force the title-screen attract-mode backdrop montage on, without needing to actually finish the game first
?resetScoreboard=1Wipe 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=NOverride 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.