pixelsp33d
A pixel-art parallax sidescroller starring Pixel, narrated by Moritz (alias pixelsp33d), antagonized by kn00t. Play it at pixelsp33d.de.
PREVIEW build - not the public site. Docs snapshot: game version
2026.08.10-3-g9836d08-dirty, published 2026-08-10.
This site collects the project’s working documents so anyone can catch up without digging through the repo:
- Story & Game Design — the concept, level design, characters, and ongoing brainstorming.
- Assets & Sources — where all the artwork and media came from, and where it ended up in the repo.
- Technical Plan — asset pipeline, engine architecture, and the phased build plan.
- Season 3 Plan — idea backlog and real-playtest bug reports for the next pass, not built yet.
- ATProto Integration Ideas — draft brainstorm on ATProto interop, idea capture only.
These are living notes from an active build, not a finished spec — expect gaps, open questions, and things that change as we go.
Story & Game Design
Split from the original design notes on 2026-08-02.
Concept
We want to build a sidescroller game with a parallax effect. The player avatar is Pixel; levelling up happens by increasing speed. Play it at pixelsp33d.de.
Narration
Levels are narrated by a real person, Moritz, alias pixelsp33d.
Level Design (initial thoughts)
Movement: Walk, Run, Jump, Sneak, Fly, Jet
Objects: Pizza, Kebap, vegan Schnitzel, Lidl Cola (“River Cola”), Football
Resolved in the technical plan: Pizza/Kebap/Schnitzel are food, River Cola is the sole speed-tier pickup, and Football is an obstacle everywhere except the Soccer scene, where it’s the objective.
Scenes: Minigolf, Lidl Lunch, Soccer game with friends, dancing lessons (Tanzstunde)
Winning Strategy
Avoid obstacles (they cost energy) and grab food items.
Narrative Sequences
Short prequel, sequel, and interlude (“Meanwhile …”) graphical sequences drive the narrative. One trophy will be Minecraft (opcraft) themed, based on opcraft (a Minecraft clone).
Characters
-
Antagonist: kn00t
-
Protagonist: left unplanned in the original notes.
Working decision in the technical plan: the protagonist is the voxel robot from the “pixel” reference art; the orange PIXEL blob avatar is used as the HUD score badge instead of the player character.
Score Display
Score display uses the provided pixel-art graphics used as the YT and Discord avatar.
References
- Pentaract: atproto video game lexicons — for scores/records interop. See the technical plan for how this project uses/extends it.
Post-Lunch Brainstorming (family session)
A moving opponent, like the snail in Commander Keen, could be an animated egg dish (Moritz dislikes Eierspeisen — egg dishes). Two opposing behaviors:
- Drop chicken — no danger, possibly even edible.
- Poisoned egg — drains energy, possibly fatal.
uervel knight is a powerup to fight the egg-dish opponent. Earned as a special achievement via either:
- A hard-to-reach item (animated, elaborate), or
- Unlocking a hidden level reachable via a portal (in Commander Keen, the player crawls into a pipe to reach one).
Resolved in the technical plan: Drop Chicken, Poison Egg, and the uervel knight are prototyped and working in the Lidl Lunch level. For the “pipe,” the Minigolf hole is a hidden bonus room, built — though scoped down to a food-only hint of the real thing rather than a second path to the knight, which stays exclusively the hard-to-reach Lidl Lunch crate. The mushroom house door and a River Cola crate stack are both built as the Underworld — real portals into a themed sub-level (a waterfall gauntlet behind River Cola in Lidl Lunch, a lava gauntlet behind the mushroom door in Soccer), each gating a one-time treasure-trove chest.
Assets & Sources
Split from the original design notes on 2026-08-02.
Task
Extract game items from the source images, pixelize them, and create a pixelized player avatar using up to 10 sprites (running, jumping, …).
- Antagonist avatar images are pixelized in the
kn00tfolder. - Score display graphics:
pixelsp33d.jpg(top-level project file, the YT/Discord avatar) — used as the HUD score badge.
Status: everything below is already fetched and committed under
assets/src/(see the table at the end). Nothing is pending. The game code itself never references any of these external URLs or original local paths — it only ever reads fromassets/gen/(the pixelized output derived from this source material). The original Discord CDN links are signed and expire, so the links below are kept as plain text, not clickable — they’re provenance records of where each file came from, not live sources meant to be re-opened or re-fetched.
Artwork provenance
Source: Discord DMs, channel 826923738804256779.
kn00t
→ assets/src/kn00t/kn00t_1.webp, kn00t_2.webp, kn00t_3.webp, kn00t_67.gif
- archived link (image, expired):
media.discordapp.net/attachments/826923738804256779/1508170937168691341/... - archived link (image, expired):
media.discordapp.net/attachments/826923738804256779/1508171007717015572/... - archived link (image, expired):
media.discordapp.net/attachments/826923738804256779/1508171154316071013/... - archived link (gif, expired):
media.discordapp.net/attachments/826923738804256779/1508171365176443171/ezgif.com-resize.gif— the “6-7” cameo needs a special appearance in the game
pixel
→ assets/src/pixel/robot3bild.webp, unknown.webp, kleki_2021_06_03.webp
- archived link (image, expired):
media.discordapp.net/attachments/634441055730663424/954380677623713852/robot3bild.PNG - archived link (image, expired):
media.discordapp.net/attachments/634441055730663424/943800321627533322/unknown.png - Pixel — 8/2/21, 3:48 PM: “Alles gute zum Geburtstag” →
assets/src/video/VID-20210802-WA0000.mp4 - archived link (image, expired):
media.discordapp.net/attachments/634441055730663424/860838783238012928/2021_06_03_0vp_Kleki_-_Kopie.png(Pixel, 2021-07-03) - interlude source →
assets/src/video/VDSL_Video_1622044168870.mp4
kn00t (nifty.ink w/ pixelsp33d)
→ assets/src/kn00t/nifty_1.webp .. nifty_4.webp
- archived link (screenshot, expired):
media.discordapp.net/attachments/634441055730663424/847135338521952295/... - archived link (screenshot, expired):
media.discordapp.net/attachments/634441055730663424/847135312429973524/... - archived link (screenshot, expired):
media.discordapp.net/attachments/634441055730663424/847135283543801876/... - archived link (screenshot, expired):
media.discordapp.net/attachments/634441055730663424/847135239666663434/...
Background art for the sidescroller
→ assets/src/bg/signal-*.jpg (5 files)
Additional kn00t artwork (gdrive)
Google Drive folder kn00t (via rclone remote gdrive:kn00t)
contained 8 native 16×16 pixel-art portraits, already downloaded to
assets/src/kn00t/gdrive/: Cyberkn00t, Divingkn00t, EvilOriginalkn00t,
Godkn00t, kn00tInBlack, kn00tOpenHair, kn00tRedShirt, Lazykn00t.
What’s already in the repo
| Path | Source |
|---|---|
assets/src/bg/*.jpg | the 5 signal-*.jpg sky/landscape photos above |
assets/src/video/VID-20210802-WA0000.mp4 | prequel source (stick-figure flipbook) |
assets/src/video/VDSL_Video_1622044168870.mp4 | interlude source (nifty.ink drawing replay) |
assets/src/kn00t/*.webp, kn00t_67.gif | the Discord CDN links above |
assets/src/kn00t/gdrive/*.png | the gdrive folder above |
assets/src/pixel/*.webp | the Discord CDN links above |
See the technical plan for what each of
these turned out to actually be and how it’s used in-game, and
tools/*.sh + tools/*.py in the repo for the pixelization pipeline
that turns this source art into assets/gen/.
pixelsp33d.de — Build Plan
A pixel-art parallax sidescroller. Player avatar Pixel, antagonist kn00t, levels narrated by Moritz (alias pixelsp33d). Levelling up = gaining speed.
1. Asset inventory (gathered — all in assets/src/)
| Source | What it actually is | Use |
|---|---|---|
pixelsp33d.jpg (150×150) | Orange blob/taco with eyes + “PIXEL” wordmark. YT/Discord avatar | HUD score badge (as specced) |
pixel/robot3bild, pixel/unknown | Voxel/Minecraft-style robot: white body, cyan eyes, copper antenna, control panel, copper feet | Player sprite base (proposed) |
pixel/kleki_2021_06_03 | Hand-drawn brown mouse/bear head | Interlude art / secret |
kn00t/gdrive/*.png | 8 native 16×16 pixel-art portraits: Cyber, Diving, EvilOriginal, God, InBlack, OpenHair, RedShirt, Lazy | Per-level boss forms — zero conversion needed |
kn00t/kn00t_1..3.webp | 3 more kn00t busts (straw hat / top hat / eyepatch), ~16×16 upscaled | Extra boss forms, dialogue portraits |
kn00t/kn00t_67.gif | 2-frame kn00t idle loop, straw hat + rainbow shirt | The “6-7” special cameo (spec req.) |
kn00t/nifty_1 | kn00t full-body wizard, yellow hat, rainbow shirt | Boss full-body sprite reference |
kn00t/nifty_2 | Red mushroom house with rainbow-lit door | Level scenery / hub |
kn00t/nifty_3 | Yellow horned creature, blue eyes | Collectible companion / powerup |
kn00t/nifty_4 | Red “piXELSP33D” wordmark | Title screen logo |
bg/signal-*.jpg ×5 | Real photos by Moritz: puffy blue sky · mackerel sky w/ tree · field w/ power pylons · 2× above-the-clouds from a plane wing | Parallax backgrounds, one set per level |
video/VID-20210802-WA0000.mp4 (3.2s) | Hand-drawn stick-figure flipbook on lined paper | Prequel cutscene |
video/VDSL_Video_1622044168870.mp4 (18.2s, w/ audio) | nifty.ink drawing replay — art appearing stroke by stroke | “Meanwhile …” interlude |
Everything downloaded: Discord CDN links all live (11 files), gdrive kn00t/ folder pulled via rclone (16 files), local photos + videos copied in (originals left in place — nothing moved or deleted).
2. Design decisions
Items (spec asked me to decide food vs. obstacle)
| Item | Role | Effect |
|---|---|---|
| Pizza | Food | +10 energy |
| Kebap | Food | +25 energy |
| vegan Schnitzel | Food (rare, Moritz’s own) | +40 energy, full heal |
| Lidl River Cola | Speed pickup | +1 speed tier — the levelling currency |
| Football | Context-dependent | Obstacle everywhere (−15 energy, knockback) — except the Soccer level, where it’s the objective |
Rationale: speed is the progression axis, so exactly one item grants it. The football flipping role between levels is the cheap trick that makes the Soccer scene feel authored rather than reskinned.
Egg-dish enemy family (from the 2026-08-02 family-lunch brainstorm)
A moving opponent in the spirit of the Commander Keen snail — Moritz’s own dislike of Eierspeisen (egg dishes) made the antagonist. Two variants of the same creature, opposite intent. Prototyped and working in the Lidl Lunch level.
| Item | Role | Effect |
|---|---|---|
| Drop Chicken | Harmless, patrols like a Keen snail | +8 energy on contact — safe, even edible |
| Poison Egg | Obstacle, patrols like a Keen snail | −35 energy on contact (harder-hitting than Football — “possibly fatal”) |
| uervel knight | Powerup | Once collected, touching a Poison Egg defeats it instead of damaging the player (+5 energy, counts as a collected item) |
The knight is deliberately scoped to counter only the egg-dish family, not Football — different scene, different mechanic, keeps each obstacle type meaningful on its own.
Secret areas & the uervel knight’s second path
The brainstorm gave the knight two ways to earn it: (1) a hard-to-reach placed item, (2) a hidden level behind a Commander-Keen-style “pipe.” Path 1 is what’s implemented today (the knight sits on a jump-up crate in Lidl Lunch). For path 2 and other secrets, three portal ideas came out of discussion — each assigned a distinct role rather than left redundant:
- Minigolf hole (chosen, built — see §4 “Minigolf: the hidden gallery”) — sinking into the cup instead of finishing the hole normally is the portal into a hidden bonus room, the project’s answer to Keen’s pipe. Confirmed after playtesting: a shallow, no-real-stakes crawlspace, fine as the very first hidden space built, but a hint of what a “real” portal becomes below — not the template to repeat. See “The Underworld” below for the actual destination the other two portals lead to.
- Mushroom house door (built — see §4 “Soccer: the mushroom-house portal”) — the kn00t nifty.ink art (
assets/src/kn00t/nifty_2.webp, red mushroom house with a rainbow-lit door) is already drawn and already “his.” Combined with a treasure trove: a separate bonus-loot secret, not knight-related, reachable through that door. - River Cola crate stack (built — see §4 “Lidl Lunch: the River Cola portal”) — a stack of crates at the back of Lidl Lunch as a crouch-in point, the first real Underworld portal (as opposed to Minigolf’s shallow hint).
The Underworld: a shared secondary realm
The unifying destination behind every real carved-space portal (i.e. every one except the shallow Minigolf crawlspace). Each portal’s entrance is themed to its own level — a mushroom door, a crate stack, maybe others later — but stepping through any of them drops the player into the same recognizable secondary world: a dark, lava-lit cave palette, distinct from every Season 1 surface level’s own daylight/dusk/cloud theming. The point isn’t narrative worldbuilding for its own sake — it’s a consistent visual grammar so a player instantly reads “I’m in the Underworld now” regardless of which level’s door they used to get there, the same way every Season 1 level’s boss encounter reads as “kn00t” regardless of which costume he’s wearing that scene.
Minigolf’s built crawlspace deliberately doesn’t qualify — it’s shallow, safe, and visually barely distinct from the surface (§4), which is exactly why it reads as a hint rather than the real thing once the actual Underworld exists to compare it to.
Architecture (built, for River Cola — see §4): a portal into a full ordinary LevelData — its own width, its own horizontal camera scroll, everything the engine already does for Lidl Lunch/Soccer/etc., just re-themed (dark palette, lava-glow tint, cave rock tiles) rather than an actual change of physics or camera behavior. Weighed against adding a vertical camera (camera.y following the player below ground within the same level — more foundational and reusable long-term, but a bigger, riskier change touching camera.ts and most of render()’s world-space draw calls). The full-sub-level route won because it reuses the entire existing rendering pipeline unchanged and gives real room to build the hazard gauntlet the design principle below calls for — a vertical camera would still only buy back the same cramped few dozen pixels Minigolf’s crawlspace already ran into.
Engine shape (built for River Cola, see §4 for the full account):
- A level-transition state machine (
enterUnderworld/exitUnderworld,src/main.ts): entering the door/crate-stack trigger saves the current level’s liveitems/hazards/solids/camera/boss/levelTimeplus player position, then swaps to the UnderworldLevelData(the “resume where you left off” pattern originally floated for the Minigolf hole, then set aside there in favor of the simpler embedded-geometry trick — it’s the right call here instead). Saving the live state (not just re-loading the level’s static template) matters: anything already collected in the main level before entering the portal has to survive the round trip. - The exit trigger reuses the existing
Boss/bossX/bossSpritemechanism rather than being a new concept — an Underworld level’s “boss” is really just its exit, checked the same way every level already checks for its boss encounter (see §4). - Each portal leads to its own Underworld-themed level (a
PortalDef.destinationon the source level’sLevelData), not one shared instance — so River Cola’s waterfall gauntlet and the mushroom house’s eventual hazard don’t have to be the same room, unified only by shared art/palette and the state-machine mechanism.
Carved-space design principle: risk the chest, don’t just find it
A carved space (tunnel/cave/crawlspace reached through one of these portals) should generally be a more dangerous place, not just a hidden freebie. Put an obstacle in it that’s very hard or impossible to clear without losing some health — lava, a waterfall, something in that family — so reaching the chest is a genuine risk/reward choice, not a guaranteed bonus. A player who’d rather stay safe can walk away and simply miss it.
This is explicitly not what the Minigolf hole does — that gallery is a safe, no-risk bonus room (redundant knight pickup + a couple of food items, no hazard inside). That’s fine for the first hidden space built and the very first path-2 route to an already-easy-to-get Season 1 reward; the principle is for the Underworld proper, where the loot matters more (see reward progression below). It’s also a natural Season 2 replay idea for Minigolf’s own gallery — see §6.
Treasure trove reward progression
What a treasure trove actually contains is meant to escalate over time, not stay static:
- Now (Season 1): just food.
- Later: a chest — a rarer, higher-value one-off pickup, not a currency. No new
Playerfield or HUD readout needed; it’s the sameItemDef/kind: 'food'-style pattern every pickup already uses, just worth more and visually distinct (a treasure-chest sprite instead of a food item). - Much later: items that unlock later levels — not just later mechanics. The concrete first example: tailored clothes, found in a treasure trove, required to get past the Tanzstunde dress-code guard (§6 Season 2 draft). This is deliberately a long payoff chain — a Season 1 (or early Season 2) trove feeding a Season 2 mechanic several levels later — so skipping a trove now costs little, and matters a lot eventually.
Mushroom house door → treasure trove (level content built; exit currently broken — see §4)
Placed in Soccer — earlier than Tanzstunde (where the tailored clothes it hands out eventually get used, per the reward progression above and §6’s dress-code guard draft), fitting the “long payoff chain” the reward progression calls for. Entered by walking into the door (the kn00t nifty.ink art, pixelized per §4). The level content — the door, the Underworld room, its lava hazard, the chest — is built and verified working on its own. Actually leaving the Underworld back to Soccer is currently broken for a reason that isn’t this level’s fault — see §4 for the full diagnosis; it needs a small fix in the shared portal engine, not more level content.
River Cola crate stack (built — see §4 “Lidl Lunch: the River Cola portal”)
Placed in Lidl Lunch — the level already themed around a Lidl grocery store, where a stack of crates is completely at home (Lidl Lunch already uses crates as its core platforming feature). Entered by crouching into the crate stack (a different entry gesture from the mushroom house’s door-walk — see §4 “Soccer: the mushroom-house portal”). Its Underworld room’s hazard is a waterfall, per its own name-driven hint from the design principle above. (The mushroom house had no equivalent name-driven hint, so it went with the other half of the “lava/waterfall family” instead — lava.)
Movement → level mapping
Each level teaches one verb and reuses the previous ones.
| # | Scene | Verb | Background | Boss form |
|---|---|---|---|---|
| 0 | Prequel | — | lined paper | — |
| 1 | Minigolf | Jump | mackerel sky + tree | Lazykn00t |
| 2 | Lidl Lunch | Walk / Run | puffy blue sky | kn00tInBlack |
| 3 | Soccer w/ friends | Run (sprint) | field + power pylons | kn00tRedShirt |
| 4 | Tanzstunde | Sneak (rhythm) | mackerel sky, dusk grade | kn00tOpenHair |
| 5 | Above the Clouds | Fly / Jet | plane wing ×2 | Cyberkn00t → EvilOriginalkn00t |
| ★ | Post-game | all | — | Godkn00t |
Divingkn00t is held for a bonus/underwater stage. The 6-7 gif cameos as a non-hostile easter egg between levels 3 and 4. Minigolf’s hole doubles as the secret portal to the hidden bonus level (see Secret areas above).
Player sprite set (10 sprites, the spec’s ceiling)
idle · run_1 · run_2 · run_3 · run_4 · jump_rise · fall · sneak · fly · hurt
Derived from the voxel robot at 16×24 px, palette-locked.
3. Technical approach
Locked decisions (2026-08-02): player = the voxel robot · vanilla TypeScript + Canvas2D · static dist/ build, hosting deferred · narration ships as timed captions with empty audio slots.
- Internal resolution 384×216, integer-scaled to viewport. Nearest-neighbour everywhere. No sub-pixel positions for sprites — that’s what kills pixel art.
- Parallax: 4 layers per level (far sky · cloud band · midground silhouette · ground), each a strip pixelized from the source photo, scroll factors
0.1 / 0.25 / 0.55 / 1.0. Speed tier multiplies scroll rate, so levelling up is visible in the background, not just a number. - Pixelization pipeline: ImageMagick script, committed and re-runnable — downsample → quantize to a shared 32-colour palette → optional Floyd-Steinberg on skies only → upscale for layer strips. Source art stays in
assets/src/, generated art inassets/gen/. Never hand-editgen/. - Cutscenes: videos → frame-extracted at ~8fps → pixelized → played back as sprite-sheet animations in-canvas. Keeps everything one renderer, no
<video>element, no codec worries. - Narration: per-level timed caption track (German, in the voice of pixelsp33d) plus an audio slot per line, empty for now. Dropping in Moritz’s recordings later is a data change, not a code change.
- Build: Vite → static
dist/, no server requirement. Deployable to any static host when the domain is ready; the atproto score layer (P6) is the only piece that will later want a Worker or equivalent.
atproto integration
Pentaract is early — only games.gamesgamesgamesgames.game is published; scores/achievements are conceptual, not implemented. So:
- Publish one
games.gamesgamesgamesgames.gamerecord for pixelsp33d (name, summary, type, modes) — real interop where it exists. - Define our own
de.pixelsp33d.*lexicons for what Pentaract lacks:run(a scored playthrough),trophy(incl. the opcraft-themed one),speedTier. Shaped to be replaceable by Pentaract equivalents when they land. - Scores render with the PIXEL blob badge, as specced.
4. Phases
| Phase | Output | Rough size |
|---|---|---|
| P0 Asset pipeline | tools/pixelize.sh, palette, assets/gen/ populated, contact sheets to eyeball | small |
| P1 Player + kn00t sprites | 10 Pixel sprites, boss sprite sheets from the 16×16 portraits | medium |
| P2 Engine core | canvas loop, fixed timestep, input, integer camera, 4-layer parallax, collision | medium |
| P3 Gameplay | energy/speed model, items, obstacles, the 5 movement verbs, boss encounters, egg-dish enemy + uervel knight — done, prototyped in Lidl Lunch | large |
| P4 Levels 1–5 | level data files, per-level backgrounds, tuning — done, see below | large |
| P5 Narrative | prequel/interlude/sequel cutscene player, caption system, narration slots | medium |
| P6 atproto + score | game record, run/trophy lexicons, score screen with PIXEL badge | medium |
| P7 Ship | static dist/ build + mobile touch controls; deployment to pixelsp33d.de left to you | small |
All 5 levels are built and playable in sequence (LEVELS array in src/main.ts), score/speed tier/uervel-knight status carry over between them, energy refills at each level start. Debug shortcut: ?level=N (0-indexed) jumps straight to a level for testing.
| # | Level | Verb | New engine work it needed |
|---|---|---|---|
| 0 | Minigolf | Jump | reveal/heal pit hazards + a level timer (see below); gaps tuned to 30px after testing showed 40px+ was too tight for the achievable jump range |
| 1 | Lidl Lunch | Walk/Run | the original P3 vertical slice, since enriched (see below) — deliberately kept the easiest level, a breather between Minigolf’s jump test and Soccer’s sprint pressure |
| 2 | Soccer w/ friends | Run/sprint | footballGoal item (same art as the obstacle, kind:'food' instead) so football’s role is level-contextual, per the design decision; scoring it plays a dedicated goal SFX (whistle trill + fanfare) and a “GOAL! +40” banner (drawGoalFanfare, goalFanfareTimer), distinct from an ordinary food pickup |
| 3 | Tanzstunde | Sneak/rhythm | waltz motion (see below), a disco ball obstacle, a hidden VIP stash, a finale cluster, a level timer — was too easy at first pass, since enriched |
| 4 | Above the Clouds | Fly/Jet | sustained-thrust flight (allowFly on LevelData, held-jump physics in Player.update), floating cloud platforms instead of continuous ground, a ceiling clamp, Cyberkn00t cameo decoration before the EvilOriginalkn00t boss reveal; float-fall cap + altitude-varied airplane obstacles added after playtesting found a degenerate winning strategy (see below) |
Two real bugs came out of building these, both fixed:
- Crouch/stand hitbox toggle: shrinking/growing the hitbox height while grounded moved the top but not the bottom, creating a false gap or false overlap that the collider misread as falling/landing — an oscillation that broke sneaking outright. Fixed by anchoring the feet (
y + heightconstant) across the toggle, inPlayer.update. - Flight had no ceiling: sustained thrust could fly the player off the top of the (vertically-fixed-camera) viewport with nothing to stop them. Added a hard
CEILING_Yclamp, same pattern as the existingFALL_OUT_Yfloor safety net.
All three secret-area portals are now built (§4): the Minigolf hole, the River Cola crate stack, and the mushroom-house door.
Lidl Lunch enrichment
Confirmed intentional: Lidl Lunch stays the easy level. Added four things without raising the floor difficulty:
- Hidden crate-stack stash: realizes the “River Cola crate stack” secret-entrance idea noted above — Lidl Lunch already has crates as its platforming feature, so it’s the natural first home for it rather than waiting for an unassigned later level. Holds a bonus cola + schnitzel; off the obvious forward path, not required to finish.
- First attempt placed the stash directly above crate3, same x-range - genuinely unreachable, not just tight. A platform stacked straight over another can only ever be hit from underneath: the rising hitbox meets its underside first, which
moveAndCollidecorrectly resolves as a ceiling bonk (zeroes upward velocity, snaps just below) - it can never continue up and over onto the top. Landing on top requires approaching diagonally, from outside the platform’s footprint while still ascending, same as any other ground-to-platform jump in this game. Fixed by offsetting the stash up-and-to-the-side of crate3 (STASH_X/STASH_Yinlidl_lunch.ts) instead of directly overhead. Verified via debug instrumentation: jumping from crate3 lands exactly on the stash’s surface, both items collect correctly.
- First attempt placed the stash directly above crate3, same x-range - genuinely unreachable, not just tight. A platform stacked straight over another can only ever be hit from underneath: the rising hitbox meets its underside first, which
- Shopping Cart obstacle (new
ItemDef,src/game/items.ts): a faster, wider-patrolling obstacle than Football (150px range vs. ~80px, speed 50 vs. 30) — gives the level its own hazard flavor instead of reusing Football everywhere. - Frozen-aisle Divingkn00t cameo: a freezer-door decoration with the Divingkn00t portrait peeking out behind it, sitting at the same spot. Purely a sight gag (no collision, no effect) — ties the “Divingkn00t held for a bonus/underwater stage” note to the Lidl setting via the diving/frozen-food pun.
- Checkout-dash finale: a tight, fast-moving obstacle cluster (Shopping Cart + Football, overlapping patrol ranges) in the last ~150px before the boss — the level’s one small difficulty crescendo, everything before it stays easy.
Playable now: the full 5-level game in sequence, not just a vertical slice.
Soccer: friendly balls, kick-to-score
Soccer’s patrolling football obstacles were identical to every other level’s — touch one, take damage. Redesigned to fit the level’s actual theme: a new soccerBall ItemDef (kind: 'obstacle', value: 0 — never costs energy), reusing the same sprite, swapped in for every patrol('football', ...) call in soccer.ts.
- Walking into one blocks you, running into one kicks it — the split is
input.runHeld(), checked once per frame inupdate(). While not running, every uncollected/unkickedsoccerBall’s current rect is added to a per-frame copy ofsolids(moveSolids) right beforeplayer.update(), somoveAndCollidetreats it exactly like solid ground — the player is physically walled off, pushed along at the ball’s own patrol pace if it’s moving into them, same as bumping into a moving platform. While running, the ball is left out ofmoveSolidsentirely, so the player passes into it and the overlap in the item-collision loop triggers a kick instead of a block. - Kicking: sets
ItemInstance.kicked = true,kickVx = player.facing * KICK_SPEED(160px/s),kickVy = KICK_VY0(a slight upward pop, -50px/s),kickBaseY(the height it was kicked from). A kicked ball’s motion is handled in its own branch at the top of the patrol/bob/waltz update loop, fully overriding its normal patrol back-and-forth for the rest of its life —kickVyaccumulatesKICK_GRAVITY(50px/s²) each frame, giving a real ~2s-hang-time arc rather than a flat slide.- First version was a flat, no-arc slide (
kickVyalways 0), and that made scoring trivial: with the ball always at exactlyfootballGoal’s height, any forward kick from any of the three balls before the goal deterministically scored — no timing or aim involved, since only x had to line up and x always eventually did. Added the arc specifically to fix this: since the same fixed arc distance (KICK_SPEED× hang time ≈ 320px) applies regardless of where in its patrol swing the ball gets kicked from, whether the landing point lines up with the goal now depends on the timing of the kick relative to the ball’s own patrol position — confirmed via a driven test at three points in the nearest ball’s 1150–1280 patrol range: kicking from x=1150 undershoots and misses, x=1245 and x=1280 both land close enough to score. A real sweet spot exists, not a guaranteed hit.
- First version was a flat, no-arc slide (
- Scoring: every frame a kicked ball is still airborne, it’s checked for overlap against
footballGoal(if not already scored) — this can happen mid-arc, not just “at landing,” though in practice the height only lines up near the end of the arc since the goal sits at the ball’s original resting height. A hit triggers the exact same effect as walking up tofootballGoaldirectly —collectFood,goalSFX, the “GOAL! +40” fanfare — just reached by a well-aimed pass instead of on foot. Missing — the ball exits the level’s width (±50margin), or falls back tokickBaseYwithout ever having scored — just marks it collected and it’s gone. No penalty either way, the level’s “friendly ball” bar for a bad kick is just that it’s now unavailable, not costly. - Verified via driven tests for all outcomes: walking into a ball (blocked, zero energy loss), kicking one from the scoring sweet spot (scores, same fanfare confirmed via screenshot), kicking one from off the sweet spot (arcs, lands short, cleanly removed, no score), and kicking one away from the goal entirely (cleanly removed once off-level).
Season 2 idea, not built: give each soccerBall its own kickVy/kickBaseY-affecting tuning (e.g. a per-instance gravity or launch speed override) so the timing sweet spot is different for each ball instead of the same fixed arc distance for all three — stops a player from just memorizing one universal “kick here” position and reusing it on every ball.
Lidl Lunch: the River Cola portal (built)
The first real Underworld portal (§2), not to be confused with the unrelated “hidden crate-stack stash” above (that’s a bonus item cache reachable by jumping, this is a whole separate sub-level reachable by crouching). A crate-stack decoration near the boss (x=1740) doubles as LevelData.portal.trigger — crouching into it (requiresSneak: true) swaps into UNDERWORLD_RIVER_COLA, a full re-themed level, not embedded geometry like the Minigolf hole.
- The Underworld level itself (
src/game/levels/underworld_river_cola.ts): its own width (500px), its own dark cave-and-water palette (tools/make_underworld_bg.sh, a hand-drawn procedural background — no source photo exists for a place that isn’t above ground, same reasoning asmake_tiles.sh’s procedural ground textures), a low rock-ceiling platform over a corridor that a patrollingwaterfallobstacle (newItemDef, tall likediscoBall) nearly fills. The ceiling sits low enough (52px above ground, less than a jump’s ~50px rise from standing) that jumping over the waterfall bonks it instead of clearing it — the design principle’s “very hard to avoid” made literal, not just a timing suggestion. Achest(newItemDef, the reward-progression’s tier-2 pickup) sits past the corridor; an exit (exit_laddersprite, repurposing the level’s ownbossX/bossSprite/bossNamefields as an exit trigger rather than a real boss fight) sits at the far end. - State preservation, verified: an item collected in Lidl Lunch before entering the portal is still collected after returning — the state machine saves the live
items/hazards/solids/camera/boss/levelTime, not just a reference to reload the level’s static template. Confirmed via a driven test: collect a pizza, enter the portal, cross the corridor (taking a hit), collect the chest, exit, and the pizza is still marked collected. - Real bug found and fixed while building this (again, only a screenshot + driven test caught it —
tscand the smoke test both stayed clean throughout): the exit restored the player’s raw savedy, which was captured while crouching (crouching is how the portal triggers in the first place). On the very next tick,sneakingrecomputes tofalse(no down-key held), height reverts to standing (22px, taller than the saved crouched box), and the now-taller box already overlaps the ground vertically beforemoveAndCollide’s horizontal pass even runs — which then reads the ground itself as a sidewall and snapsxto its left edge (0). Fixed by saving/restoring the player’s feet position (y + height, invariant to crouch state) instead of rawy, and explicitly forcingsneaking = falsebefore deriving the restoredyfrom it. - Exit trigger reuses
Boss, not a new mechanism: the Underworld level’s ownbossX/bossSpriteare checked via the exact sameboss.checkReached()every level already uses for its real boss encounter;update()just branches on whetherunderworldReturnis set to decide whether reaching it means “exit the Underworld” or “you’ve won this level.” - New sprites (
tools/make_item_sprites.py):chest,waterfall,crate_stack(the entry decoration),exit_ladder(the exit visual). New tile (tools/make_tiles.sh):cave_floor. - Built in parallel with background music (§4 “Background music”) as two independent subagent workstreams, merged after both finished. Git merged cleanly (no textual conflicts in
main.ts/PLAN.md), but a clean merge doesn’t mean the features actually work together — two gaps only surfaced by re-testing the merged result directly, not by either subagent’s own tests (each only knew about its own feature):- Entering the Underworld called
playMusic(level.id)same as any level, but'underworld_river_cola'isn’t a real track name, so the call silently no-opped — Lidl Lunch’s music kept playing underground by accident, and exiting never calledplayMusic()at all, so the “return to normal” case only worked because nothing had actually changed. Fixed by havingenterUnderworld()explicitlystopMusic()(silence fits a risky cave, and no track exists for it yet) andexitUnderworld()explicitly restore the origin level’s track — both now correct by design, not by accident. - A leftover
console.log('DEBUG exitUnderworld ...')had survived the subagent’s own testing (aconsole.logisn’t aconsole.error, so it’s invisible totools/smoke_test.py’s error-only check) — caught by grepping the merged files for debug artifacts before considering the merge done, not by any automated check.
- Entering the Underworld called
- Instant swap replaced with a falling/climbing vignette transition, after playtesting the hard cut felt jarring. Revisits the vertical-camera-vs-portal decision from §2 — not by switching to a vertical camera (still the bigger, riskier change; still not what actually fixes an abrupt cut), but by wrapping the same
enterUnderworld()/exitUnderworld()calls in a scripted beat instead of calling them immediately on trigger. Scoped to Underworld enter/exit only — normal level-to-level advancement already has its own breakpoint (the LEVEL COMPLETE screen) and stays instant.- A
'transition'state (alongside the existing'won'/'gameComplete') freezes gameplay for two ~0.5s phases,closingthenopening, around the moment the real level swap happens: trigger the portal →closingplays out against the still-loaded old scene → att=1the realenterUnderworld/exitUnderworldcall fires (unchanged) →openingplays out against the now-loaded new scene →'playing'resumes. The player’s rendered frame is forced tofall(entering) orjump_rise(exiting) for the whole beat, with a small scripted vertical drift - cosmetic only, not real physics;player.update()never runs during a transition. - Real bug found while building the vignette: the first attempt drew a full-canvas black
fillRect, then tried to cut a circular hole withglobalCompositeOperation = 'destination-out'- this rendered solid black at every radius, hole included. Canvas2D has no actual layers: thefillRecthad already overwritten the game scene’s pixels with opaque black before the erase ran, so erasing just revealed transparency (the page behind the canvas), not the frame drawn earlier in the same pass - there’s nothing to “reveal” once a pixel’s been overwritten. Fixed by filling one path made of the full-canvas rect plus the circle, using the'evenodd'fill rule - this paints black everywhere except inside the circle, because the hole’s interior is simply never drawn over in the first place, so whateverrender()already put there stays untouched. Caught by taking screenshots at several points during the transition, not just before/after - a fully black frame reads identically to “no bug, just fully closed” unless you check it’s still black at a radius large enough that it very much shouldn’t be.
- A
Chest count in the HUD
Player.chestsFound (a plain counter, tracked separately from collectFood’s energy/itemsCollected bump — same pattern as hasKnight getting its own badge instead of just folding into score) increments via a new collectChest(), called from main.ts’s existing food-branch special-casing (alongside footballGoal/dropChicken) rather than adding a new ItemKind. Drawn in drawHud as a small chest icon + number, in a fixed slot past the knight badge regardless of whether that one’s currently shown — simpler than dynamically repacking the row around a conditional neighbor. Not portal-specific: works for any chest, so River Cola’s and (once built) the mushroom-house trove’s both count toward the same total.
Soccer: the mushroom-house portal (built)
The second Underworld portal, per §2. Built as a separate subagent workstream from the River Cola portal above, reusing its generic machinery (LevelData.portal, enterUnderworld/exitUnderworld, allLevelData()) untouched — this section is new level content only.
- A walk-in door, not crouch-in:
requiresSneakomitted onPortalDef, the deliberately different gesture from River Cola’s crate stack (§2). The door art is the kn00t nifty.ink mushroom house (assets/src/kn00t/nifty_2.webp) — unlike River Cola’s crate stack (no source art existed, hand-drawn instead), this one had real source art to convert. Pixelized with the same block-quantize-then-nearest-neighbor-scale techniquetools/pixelize_bg.shalready uses for photo backgrounds, adapted down to item-icon size (tools/pixelize_mushroom_door.sh) — cropped to just the house first (the source canvas also has an unrelated wizard figure and a lot of white space beside it, per the asset inventory in §1; quantizing the whole canvas at once diluted the result into a washed-out blob until the crop was added). - The Underworld level itself (
src/game/levels/underworld_mushroom_house.ts): its own lava-themed palette (tools/make_underworld_lava_bg.sh— same rock-silhouette-plus-one-glowing-seam structure as River Cola’s background script, re-themed warm instead of cool, a shared Underworld grammar per §2 rather than a from-scratch look), reusing River Cola’scave_floorground tile (the floor doesn’t need to be lava-specific, just cave-like). The hazard is lava (newItemDef), completing the design principle’s “lava/waterfall family” — a wide static pool rather than River Cola’s patrolling waterfall, enforced by distance instead of a low ceiling: atw:105, it’s wider than the ~93px of horizontal travel a max-speed-tier running jump covers over its full airtime (JUMP_VELOCITY/GRAVITYinsrc/game/player.ts), so no jump clears it regardless of speed tier — a distinct mechanism from River Cola’s, not a rerun of it. Achest(the sameItemDefRiver Cola already uses, no new reward item needed) sits past the pool; the exit reuses the samebossX/bossSprite-as-exit-trigger pattern. - Real balance bug found and fixed (only a driven test with actual frame-by-frame logging caught it —
tscand the smoke test stayed clean throughout, and even a first driven pass looked fine until the numbers were checked closely): the first version of the lava pool wasw:140, generously past the jump-distance minimum. ButtakeHit’s knockback pushes away from whatever side of the hazard was touched first — for a wide static hazard, that’s backward, against the direction of travel. At walk speed, one hit’s knockback (an enforced ~0.35s at −90px/s) very nearly cancels the forward distance regained during the rest of the 1.0s invulnerability window, so crossing a wide pool slowly meant taking a hit almost every cycle — confirmed via a driven test: energy dropped 100→70→40→10 in a single crossing attempt at walk speed, nearly enough to force a respawn from chip damage alone, not the “one clean hit” River Cola’s crossing takes. Narrowed tow:105(still safely past the jump-distance minimum, with margin) to shorten how much distance is left to grind through after the first hit — this reduces exposure but doesn’t eliminate the underlying dynamic; the fix is documented directly on theItemDefinsrc/game/items.tssince it’s a property of any sufficiently wide static obstacle in this engine, not something specific to this one level. At running speed the crossing is clean (2 hits, comparable in severity to River Cola’s 1), confirmed via a driven test reaching the chest and beyond. - A real bug found by this workstream, fixed afterward in the shared code: reaching the exit didn’t actually return the player to Soccer. Diagnosed precisely via a driven test:
enterUnderworld()saves the player’s position at the exact moment the entry trigger first overlaps — which, for any walk-in (non-requiresSneak) portal, is necessarily still inside the trigger’s own bounds (that’s what “overlapping” means). Restoring that exact position on exit meant the very next frame’s portal check still found the player standing inside the trigger, silently callingenterUnderworld()again — looked like the game was stuck respawning inside the Underworld forever,level.idnever changing, because it was actually exiting and immediately re-entering every time. River Cola’s crouch-gated trigger had avoided this by accident, not by design:exitUnderworld()already forcedsneaking = falsebefore restoring position, sorequiresSneak: true’s condition was guaranteed false right after an exit even though the position still overlapped the trigger rect — a walk-in door has no equivalent guard, so it looped. Fixed generally (not just for this portal) inexitUnderworld(): instead of restoring the raw saved x, the player is placed just outside the trigger’s left edge (trigger.x - player.width - 4) — always outside the overlap regardless of which gesture the portal uses, rather than relying on the crouch-reset side effect. Confirmed fixed via a driven round trip through this exact portal: enter, cross the lava, collect the chest, reach the exit, land back in Soccer, and stay there (no re-entry loop) on a follow-up check a second later. - New sprites (
tools/make_item_sprites.py):lava. New sprite via a different pipeline (tools/pixelize_mushroom_door.sh, not hand-authored ASCII art):mushroom_door. New background (tools/make_underworld_lava_bg.sh):underworld_lava_far/underworld_lava_near. - Color-rotation shimmer on both Underworld hazards (
lavaandwaterfall):drawItems()applies a slowctx.filter = hue-rotate(...)(a full cycle every 9s) just for these two sprites, reset immediately after so it doesn’t leak onto anything drawn next. Cheap way to make a static single-tone texture read as flowing/shimmering without needing actual animated sprite frames. - Elevated onto a crate, and bigger: originally sat directly on the ground at 24×28, easy to brush against just running past on the way to the boss. Moved onto a dedicated crate platform (
CRATE_Y = 150, the same jumpable-crate height used throughout this game) and enlarged to 36×42 — reaching it now takes a deliberate jump onto a specific platform, not incidental contact while sprinting through, which was itself already most of the fix for accidental re-entry even before theusedgate below existed. PortalDef.used, so the chest can’t be farmed:loadLevelData()rebuilds a fresh, uncollected copy of a destination’sitemson every visit — meaning before this, re-entering the same portal any number of times would hand out a brand new, re-collectible chest each time. Fixed generally (both portals, not just this one) by addingused?: booleantoPortalDef, settruethe moment a portal is first entered and checked alongside the existing overlap/requiresSneakconditions — the trigger simply does nothing once used, for the rest of that page session. Applies the instant you step through, not conditional on whether the chest was actually collected — skip it and it’s gone for good, matching how missing a kicked ball at the goal is also just gone, no do-overs.
Minigolf pit hazards: reveal, heal, and a level timer
Originally Minigolf’s gaps were static — always-open pits, visually flagged. Playtesting surfaced two problems: gaps looked identical to solid ground until you were on top of them (a real rendering bug — the ground tile was drawing across the whole viewport regardless of actual collision data, painting right over the pits), and the flags were unconnected decoration with no functional meaning. Fixed both, then extended it into a proper mechanic per a design discussion:
- Reveal-on-approach: a pit looks like ordinary solid fairway until the player gets within
HAZARD_REVEAL_DISTANCE(130px) of it, at which point it opens into a real gap — flag pops up, sand-trap texture appears, and the underlying solid rect is pulled fromsolids. 130px was chosen so the ground never visibly disappears out from under the player’s feet — always reveals with room to react. - Heal-or-permanent, mixed:
HazardDef.heals— pits early in the level re-seal back to solid groundHAZARD_HEAL_DELAY(2s) after reveal, forgiving a hesitant player. The last three pits (near the boss) never heal once opened, so Jump stays mandatory for the finish rather than fully skippable by waiting. - Level timer:
LevelData.timeLimit(30s for Minigolf) — running out respawns the player at the level start and resets the clock and all hazard state, exactly like falling into a pit. This is what makes jumping the reliably winning strategy: waiting out every healable pit instead of jumping eats real time off the clock. - Implementation lives entirely in
src/main.ts(HazardState,resetHazards(), the reveal/heal loop inupdate()) plusHazardDefonLevelData.solidshad to change from a direct reference tolevel.platformsinto a per-load copy — hazards mutate it at runtime (removing/re-adding rects), and mutating the shared static template would have corrupted the level for every subsequent playthrough. - Flags are always visible, from the moment the level loads, regardless of whether that pit is currently open, healed, or never yet triggered — like real minigolf, where a flag marks a hole permanently, not just when you’re about to putt into it. Deliberately not tied to hazard reveal state; see Seasons below for why.
- Bug: timer didn’t reset on an ordinary respawn.
timeOut()(the clock hitting zero) always resetlevelTime, but a different respawn trigger — falling into a still-open pit, or energy hitting 0 — only reset hazard state, not the clock. Falling late in a run-through would respawn you at the start with only a few seconds left on a timer that never gave you a fresh 30s. Fixed: anyplayer.justRespawnednow resetslevelTimetoo, not just hazards - a respawn is a fresh attempt in every sense, clock included.
Minigolf: the hidden gallery (the Keen-pipe portal, built)
The first hidden secret actually built, per §2’s “Secret areas” decision — pit 3 (x=700) doubles as “the cup,” entered by deliberately falling in rather than jumping it. (Originally built under pit 1, then moved to pit 3 and reworked to food-only after playtesting — see below.)
- Marked, not hidden: pit 3 uses a gold flag (
HazardDef.flagSprite, new field) instead of the ordinary red one, so it reads as different before anyone falls in — same silhouette, different color, no new shape to learn. heals: false, unlike the other early pits — once opened it never re-seals, so there’s no risk of the ground healing shut while the player is exploring underneath (which would have needed real fairness-checking logic to avoid trapping them).- No separate level, no camera change: the crawlspace is just extra geometry — a thin roof crust over the approach side (
TUNNEL_ROOF_H = 5px,TUNNEL_CRUST_W) over a floor 16px below it (TUNNEL_FLOOR_Y), carved directly into the level’s existing ground rects. Clearance (16px) fitsCROUCH_H(14) with margin but notSTAND_H(22) — so standing upright is only possible directly under the pit’s own opening; moving sideways requires crouching, an emergent consequence of the geometry rather than a special-cased rule. - The floor (
TUNNEL_FLOOR_W) has to be wider than the roof crust, with real margin past the pit’s far edge. First attempt made the floor exactly as wide as the crust plus the pit’s own 30px opening (stopping at x=730, the pit’s far edge) - falling straight down landed fine at low speed, but a player entering anywhere across the pit’s width while running (up to 140px/s at max speed tier) keeps drifting horizontally during the ~0.2s fall to the floor, easily carrying them past a floor edge with zero margin - they’d sail right over it into the untouched gap beyond and free-fall toFALL_OUT_Y, respawning instead of landing. Fixed by widening the floor to extend ~50px past the pit’s far edge (TUNNEL_FLOOR_W = 140, covering 640-780), enough margin for worst-case entry position + max-speed drift combined. Confirmed via a driven test entering at max speed tier: lands reliably now instead of falling through. - Positioned so it doesn’t disturb an existing mandatory jump: pit 3 already sits next to a genuine gap (x=730-762, crossed via a floating crate) that requires a real jump in Season 1 today. The crust only extends before the pit (approach side); the far side is left completely untouched so that existing jump isn’t accidentally paved over with a new walkable roof.
- Climbing back out needs directional input, not just a jump — jumping straight up from the middle of the pit’s own opening just falls back into the same hole, same as jumping straight up in the middle of any gap in this engine would. Moving toward the crust while jumping clears it, same physics as clearing any other gap in the game; not a special case.
- Two rendering bugs found and fixed while building this (neither caught by
tscor the smoke test — only an actual screenshot surfaced them):- Items placed below
groundYwere drawn fully visible above the grass line, spoiling the secret before the player ever got there — this engine has no depth-occlusion; a later draw pass always paints over an earlier one regardless of world-space position. Fixed with a newItemInstance.hiddenUntilHazardfield (an index intolevel.hazards) that gates both drawing and collision until that hazard’s been revealed. - The thin roof-crust rects rendered as flat black bars instead of blending with the grass, because
drawGround()’s thick-vs-thin heuristic (p.h > 20) treated them like a small floating platform. Fixed by also treating anything flush with the main ground line (p.y === level.groundY) as “thick ground” for rendering purposes, regardless of its actual (thin, gameplay-only) collision height.
- Items placed below
- Reward: food only — after playtesting, moved off being a second path to the uervel knight (that path stays exclusively Lidl Lunch’s jump-up crate) and simplified to a few food items. No hazard inside — a deliberately safe, low-stakes bonus, explicitly a hint of what a real Underworld portal becomes (§2) rather than a meaningful reward in its own right.
- Visual tradeoff, decided: because there’s no vertical camera, the gallery only has ~24px of screen space to work with and reads as “crouching near the sand trap” rather than a dramatic underground room. Confirmed acceptable as-is rather than investing in a recolored tile or deeper engineering — discovery here is about the gold flag and choosing to fall in, not a dramatic visual reveal.
Tanzstunde enrichment
Confirmed too easy at first pass — only one hazard type (the dancer), no secrets, no stakes. Added:
- Waltz-rhythm dancer motion: replaced the smooth continuous cosine bob with a real 3/4-time box step.
ItemInstance.waltzBaseX/BaseY/DipAmplitude/SwayAmplitude/Speed/Phase, computed inmain.ts’s per-frame item loop via a 3-beat clock (beatIndex = floor(beatT) % 3): beat 1 is a sharp downbeat dip (the only dangerous moment, dodge by crouching, timing unchanged from before), beats 2-3 are a gentle side-sway at safe height (pure choreography, no added collision risk).waltz()helper inhelpers.ts, alongside the existingbob()(kept for any future non-waltz vertical hazard). - Disco Ball obstacle (new
ItemDef): tall enough (32px) to overlap both standing and crouching hitboxes, so dodging it means actually stepping out of its horizontal patrol path — crouching alone doesn’t help. Gives Sneak-level hazards some variety beyond “duck here.” - Hidden VIP floor stash: same pattern as Lidl Lunch’s — an elevated platform reachable by an ordinary ground-to-platform jump (not stacked over anything, no repeat of that bug), holding bonus items off the main path.
- Finale dance number: three tightly-phased waltzing dancers right before the boss, close enough together that sustained crouch-walking matters more than single dodges — the level’s one crescendo, mirroring Lidl Lunch’s checkout-dash.
- Recital timer (
timeLimit: 40): same forgiving contract as Minigolf’s. Crouch-walking the entire level is possible (SNEAK_SPEED is slower than Walk/Run) but risks the clock — rewards crouching only exactly when a dip demands it. - Tango variant (
ItemInstance.waltzSharp,sharpPulse()inmain.ts): a handful ofdancePartnerinstances passsharp=trueto thewaltz()helper, swapping the smoothMath.sinpulse for a trapezoid attack-hold-release shape (sharpPulse: linear ramp up over the first 15% of the beat, hold at full for the middle 70%, linear ramp down over the last 15%) — same beat clock, same dip/sway amplitudes and timing window as a regular waltz dancer, just a snappier, more dramatic motion instead of a continuous sway. Reuses every existing waltz field; no new dodge logic needed since the danger window (the downbeat) is unchanged. - Break Dancer obstacle (new
ItemDef, ground-level): unlike every other Tanzstunde hazard, this one sits directly on the floor rather than at head height, so crouching — which only shrinks head clearance, never lifts the feet — does nothing against it. The only dodge is jumping over it. Placed via the plainonGround()helper (no waltz motion), giving the level a hazard that specifically tests Jump rather than Sneak, alongside the dip/sway dancers and the disco ball.
Above the Clouds: the ceiling-rush exploit
Playtesting found a degenerate winning strategy: hold thrust to the CEILING_Y clamp, coast the whole level at full run speed (horizontal movement is unaffected by vertical state), then release thrust once near the end and drop at normal (fast, precise) gravity right onto the final platform. Trivializes all the intermediate narrow-platform navigation the level is meant to test.
FLOAT_FALL_CAP(60px/s): in a fly-enabled level, releasing thrust no longer means falling at normal gravity (capped 600px/s) — it falls slow and floaty instead. At that speed combined with normal run speed, releasing thrust anywhere drifts 150-210px horizontally before touching down, overshooting any of the 32-48px intermediate platforms entirely. Only the intentionally-wide final platform (380px, the boss arena) is still reachable this way — precisely landing on any narrow platform in between now requires actively managing thrust (tap up, drift down a bit, tap again), not one giant climb-and-coast. Verified via debug instrumentation:vycorrectly caps at 60 during an un-thrusted fall in this level, confirmed unchanged (600) elsewhere.- Airplane obstacles at varied altitude (new
ItemDef, replaced an earlier ceiling-patrolling-bird draft): sevenaltitudePatrol()zones spread across the level’s full vertical range (yfrom ~25 near the ceiling down to ~150 near the floor, not clustered at any one height). The float-fall cap alone still lets a player force a straight climb to the ceiling and coast there horizontally between obstacle encounters; spreading airplanes across every altitude band means that shortcut still has to thread traffic at whatever height it passes through, so real vertical navigation (not just “go up once, then it’s a footrace”) is required throughout, not only during the climb/descent moments.altitudePatrol()lives locally inclouds.ts(parallel to the sharedpatrol(), which is groundY-relative and meaningless in a level with no ground).
HUD & audio clarity pass
Playtesting the energy bar and egg-dish family surfaced a few things that weren’t legible enough:
- Heart icon next to the energy bar (
hud/heart.png, drawn indrawHud) — the green bar alone didn’t read as “health” at a glance; a heart makes that unambiguous the way the speed-tier bolts already do forspeedTier. collect_dropchickenSFX: Drop Chicken (the harmless half of the egg-dish family — “no danger, eatable”, per STORY.txt) was silently reusing the genericcollectblip, same as any other food pickup. Gave it its own light double-cluck instead, so the egg-dish family reads as a matched pair by ear, not just by sprite: a comedic, harmless sound for Drop Chicken vs.hurt’s harsh buzz for a Poison Egg hit vs.defeat_enemy’s triumphant zap when the uervel knight beats one instead of taking damage. The knight pickup itself (collect_powerup) and the knight-defeats-egg case (defeat_enemy) already had their own distinct, contrasting SFX from Season 1 — this pass filled in the one missing case, Drop Chicken.- Gotcha hit while wiring the heart icon up: a new HUD sprite has to be added to
main.ts’s explicitpreload()list, not just generated intoassets/gen/hud/—assets.tsonly resolves paths that were actually requested, it doesn’t auto-discover the directory. First pass generated the sprite but never rendered it until this was caught by an actual screenshot, not just the type-check/smoke-test pair (neither one catches a missing-but-silently-undefined image).
Background music
Season 1 had SFX but no music - silence under the pixel art during actual gameplay. Filled the gap, same procedural approach as the SFX (tools/make_sfx.py → tools/make_music.py, pulse/triangle waves + envelopes, numpy → WAV, no samples/licensing concerns).
- One track per level, each matching its mood via key, tempo, and instrumentation rather than just tempo alone: Minigolf breezy (relaxed major-key arpeggio, 100 BPM), Lidl Lunch upbeat (bouncy staccato bass + kick/hat bounce, 128 BPM), Soccer driving (pumping bassline, kick-every-beat, 142 BPM), Tanzstunde a genuine 3/4 waltz (oom-pah-pah bass/chords, matching the level’s existing waltz-motion dancers, 132 BPM), Above the Clouds airy (slow sustained triangle-wave pads, no percussion, 74 BPM). All generated by a small step-sequencer helper in
make_music.py(note-name parsing, chord/bass/melody voices, optional kick/hihat) rather than one-off signal math per track like the SFX file uses - there’s enough shared structure across 5 real compositions that the abstraction earns its keep here, unlike SFX’s one-shot blips. - Seamless looping:
AudioBufferSourceNode.loop = true(native looping, not manual re-triggering) — every voice’s envelope decays to ~silence by the end of its last note and a tiny (4ms) safety-net fade is applied to the whole buffer’s start/end, so the loop wraps with zero discontinuity. Verified numerically (the sample at the very end and the sample at the very start are both ~0, not just close). - Respects the existing gesture-gate:
unlockAudio()already resumes the SFXAudioContexton first keydown/pointerdown (browsers block audio until a gesture). Music hooks into the same context and gate rather than duplicating it, but needed one addition: a level can load before that first gesture (e.g. the very first level on page load), soplayMusic()records what should be playing (desiredMusic) even when it can’t actually start yet, andunlockAudio()checks that once the context resumes - otherwise the first level’s music would never start until the player advanced to a second level. - Track switching fades, not a hard cut: ~200ms linear ramp out on the old track (avoids an audible click) before a ~200ms ramp in on the new one, on level load and at game-complete. Considered a full crossfade (both tracks briefly overlapping) but skipped it as unnecessary complexity for a level-to-level transition, which is already a case where the picture changes on the same beat and momentary silence during a natural break doesn’t fight the moment the way it would mid-track.
- Verified with a driven test (not just
tsc/the smoke test, per the heart-icon lesson above): confirmed each of the 5 levels starts its own correct track, confirmed the pre-gesture defer-then-resume path actually starts audio once unlocked, and confirmed switching/stopping mid-session behaves correctly - all via a temporary debug hook, removed before finishing. - Music mute toggle (
Mkey): added specifically to audition new SFX against a quiet background rather than over the score, and left in as an ordinary player-facing option.setMusicMuted()/toggleMusicMuted()/isMusicMuted()inaudio.tssilence the musicGainNodein place rather than stopping the source node - unmuting resumes exactly where the track already was, not from the start.startMusicNow()reads the muted flag too, so the mute stays in effect across a level change instead of only applying to whatever track was playing at the momentMwas pressed. A small “music muted (M)” HUD line (main.ts’srender()) confirms the state - checked directly via a driven test (audio.ts’sisMusicMuted(), not just the HUD text) that two presses round-trip correctly.
New SFX: portals, chests, kicks
Four new procedural SFX (tools/make_sfx.py, same pulse/noise/envelope approach as every other SFX in the file) for this session’s new mechanics, none of which had a dedicated sound before (portal enter/exit had none at all; chest and kick were both silently reusing an unrelated existing SFX):
portal_enter/portal_exit: a descending vs. ascending warp sweep (mirror images of each other) with a bit of noise swirl - “sinking away” vs. “climbing back out.” Triggered once, instartTransition(), right as the vignette begins closing - not insideenterUnderworld/exitUnderworldthemselves, which only fire at the transition’s midpoint once the screen is already covered.chest_open: a wooden creak followed by a longer, brighter sparkle thancollect_powerup’s chime - previously chests silently reusedcollect_powerupoutright, giving the knight pickup and a treasure trove find the exact same sound despite being unrelated moments.kick: a snappier, more percussive impact thanland’s soft thud - previously the kick trigger reusedlanddirectly (a stand-in noted at the time), giving a football kick and touching down from a jump the same sound.- Verified via driven tests exercising all four trigger points (a full River Cola portal round trip, plus a Soccer ball kick) with no console/page errors - headless testing can’t confirm audible output, so this checks that every new trigger path executes cleanly and that
preloadSfx()successfully decodes all four new files at startup (covered by the smoke test passing), matching the verification approach already used for the music system above.
Seasons: replaying the same scenes with escalated challenge
The game is structured in seasons. Season 1 is what’s built now — one playthrough of the 5 levels. Season 2 (and beyond) revisits the same scenes — same backgrounds, same layout, same “hole” locations — with harder mechanics layered onto them, rather than being new levels. Minigolf’s pits going from one-way reveal/heal to the rhythmic open/close draft below is the first planned instance of this.
This is why Season 1’s flags are always-visible course markers rather than reactive warnings: a permanent flag at a hole location works as-is when Season 2 swaps in a rhythmic hazard at the same spot — the flag keeps meaning “there’s a hole here” through the pit’s closed/safe phase, when the ground itself gives no visual clue. If flags were reveal-triggered instead, Season 2 would need to redesign the marker, not just the mechanic underneath it.
Delivery mechanism (built): Season 2 is reached as New Game+, offered once the player actually finishes Season 1 — beats all 5 levels and reaches the existing gameComplete state. main.ts adds season: 1 | 2 and activeLevels (starts as LEVELS, the existing Season 1 array); loadLevel()/advanceLevel() now read from activeLevels instead of hardcoding LEVELS. At gameComplete, season === 1 && input.jumpPressed() calls startSeason2() (sets season = 2, points activeLevels at a new LEVELS2 array, loads its first level) — the exact same “press jump to continue” input path state === 'won' already uses between ordinary levels, no new menu/UI system. drawGameCompleteOverlay() shows “SEASON 1 COMPLETE” + a continue prompt when season === 1, or the original plain “GAME COMPLETE” (no prompt) once Season 2 itself finishes — reaching gameComplete a second time just stays there, no Season 3 hook invented.
LEVELS2 = [MINIGOLF, LIDL_LUNCH, SOCCER, TANZSTUNDE_S2, CLOUDS] — only Tanzstunde has a real Season 2 pass (below); the other four are explicitly placeholders, reusing the exact same Season 1 LevelData objects rather than copies. Real consequence of that: any state a level mutates on itself carries over from whatever happened in Season 1 — concretely, PortalDef.used (River Cola’s and the mushroom-house’s portals). Enter either once during a Season 1 playthrough and it’s already “used” when that same placeholder object reappears in Season 2 — not a bug, just an honest limitation of reusing live objects instead of building real Season 2 versions of those four levels, worth fixing if/when they get an actual rebuild.
Interlude animation, still not built: finishing Season 1 was meant to get a celebration interlude before Season 2 starts, in the same spirit as the prequel/interlude/sequel cutscene concept from the original design brief (§1) — a graphical sequence, not just a text screen. Out of scope for this pass (a separate, less-specified piece of work); drawGameCompleteOverlay()’s text prompt is what ships for now.
5. Open dependencies
- Narration audio from Moritz (not blocking — caption-first). Distinct from background music, which is now built (§4 “Background music”) — narration is spoken-word per-level commentary, still unrecorded; music is the procedural instrumental loop already playing under it.
- Hosting choice for pixelsp33d.de, deferred by decision. Build stays host-agnostic until then.
- Pentaract’s score/achievement lexicons don’t exist yet; our
de.pixelsp33d.*records are the stopgap and may need migrating if upstream lands equivalents. - Discord CDN links in
ASSETS.txtare signed and will expire — assets are already pulled intoassets/src/, so this only matters if we need re-downloads.
7. Hosting, docs, and public launch (build complete 2026-08-10)
The game went from “runs on the local dev server” to actually public in one session,
alongside a matching push on documentation infrastructure. Why now: a real demo
(playing it live) went well enough that publishing it — as the birthday gift it
actually is — stopped being hypothetical. Three access tiers exist now, matching the
pxl · dev / pxl · playtest / pxl · live TV bookmark names:
pxl · dev—http://pad.local:5175/, the existing Vite dev server + TV test-redirect flow (§4/NEXT.md, unchanged).pxl · playtest—http://192.168.178.42:8095/, a static build ontrullala(the homelab test-offload box, see §“Fleet offload” in NEXT.md), served by apixelsp33d-demo.servicesystemd unit (python3 -m http.server 8095).tools/deploy_trullala.shbuildsdist/and rsyncs it there. LAN-only, laptop- independent — survives the dev server not running. Bookmarked by raw IP rather thantrullala.localon purpose: easier to edit just the last octet on a TV remote than retype a hostname (seememory/reference_tv_testing.md).pxl · live—https://pixelsp33d.de/, the actual public release.
Public hosting: Cloudflare Pages
The game is 100% static (no backend anywhere in src/ — scoreboard is
localStorage, audio is bundled WAV fetches), so Cloudflare Pages was the natural
fit over self-hosting on the homelab or a rented VPS — free, zero maintenance, no new
attack surface next to already-running personal services on the homelab boxes.
As built: Pages project pixelsp33d, created once via wrangler pages project create; routine redeploys via tools/deploy_cloudflare.sh (npm run build then
wrangler pages deploy dist). pixelsp33d.de’s nameservers moved from
domaindiscount24 to Cloudflare’s assigned pair; the apex custom domain needed the
Cloudflare API directly (POST /accounts/{id}/pages/projects/pixelsp33d/domains) since
this wrangler version has no pages domain CLI subcommand at all, plus a CNAME
record at the apex (pixelsp33d.de → pixelsp33d.pages.dev, proxied — Cloudflare’s
normal apex-flattening pattern). Verified live with tools/smoke_test.py --base-url https://pixelsp33d.de.
Docs site: public + a private preview tier
The existing mdbook docs site (docs/, previously just pixelsp33d-docs.pages.dev)
gained a real custom domain and a second, private deployment:
- Public:
tools/deploy_docs.sh→https://docs.pixelsp33d.de/(same Pages account/pattern as the game). Every build is version-stamped viagit describe --tags --always(matching the existingYYYY.MM.DDannotated-tag convention), shown in the sidebar header and browser tab title on every page (MDBOOK_BOOK__TITLE, mdbook’s own env-var config override — nobook.tomledit needed), with the dirty-check scoped to only the paths that actually feed the book so unrelated uncommitted files elsewhere don’t falsely mark it stale. - Private preview:
tools/deploy_docs_preview.sh→https://preview.pixelsp33d.de/, a separate Pages project (pixelsp33d-docs-preview) gated behind a Cloudflare Access application (Zero Trust teamholy-art-cc57.cloudflareaccess.com, policy: Torsten’s two emails, One-Time PIN login) — same mdbook source, built with a “(PREVIEW)” label so it’s never visually confused with the public site. Intended workflow: stage doc changes here for review before promoting to public. - Two new mdbook pages, both live-
{{#include}}s of root files so they can’t drift: Season 3 Plan (←MECHANICS-SEASON3-PLAN.md) and ATProto Integration Ideas (←ATPROTO). Per Torsten’s explicit call, most other all-caps root docs (MECHANICS.md,NEXT.md,AUDIO.txt,PLAYTEST-FINDINGS.md, the dated*-2026-08.mdaudits) stay deliberately out of the book. - Real Cloudflare gotcha, unresolved: creating the Access application via API
(
POST /accounts/{id}/access/apps) returned1010: auth.forbiddenthrough both a purpose-scoped API token and the account owner’s own full-permission OAuth session — DNS records and Pages custom domains both worked fine via the same token, so the gap is specific to Access-app creation on this account. Worked around via the dashboard (a 2-minute manual task); root cause never identified.
Credentials: a proper .keys/ entry, not ad-hoc tokens
A purpose-scoped Cloudflare API Token (Zone → DNS → Edit limited to pixelsp33d.de,
Access: Apps and Policies → Edit, Pages → Edit) now lives at
cloudflare-pixelsp33d/api-token in the shared ~/txt/tracker/.keys/ gpg store,
entered via the established tmux-mediated flow so the raw value is never exposed to
Claude directly.
Launch: promo assets + a real ATProto handle
Three collage images (promo/ — a 2×2 grid, a widescreen triptych, and a portrait
poster, all built from real Playwright-captured gameplay screenshots plus the
existing wordmark/mascot badge assets via promo/make_collage.py) shipped alongside
the actual bsky launch post
(bsky.app/profile/tgoerke.bsky.social/post/3msqf3kxu4c2i) announcing the game as a
birthday gift, with a follow-up reply pointing at the docs site. pixelsp33d also
picked up its own ATProto identity, pixelsp33d.eurosky.social — a placeholder handle,
intended to move to a pixelsp33d.de-based DNS handle later (ties into the
de.pixelsp33d.* lexicon ideas below and in ATPROTO).
6. Season 2 (build complete 2026-08-06/07)
Every draft in this section shipped in a single intensive two-day push. Per the Seasons model (§4), none of it replaces Season 1 — it’s what the same scenes grew into on the New Game+ pass, reached via the gameComplete screen after finishing Season 1. All 5 levels now have a real Season 2 escalation; tools/smoke_test_season2.py covers all of it end to end, and tools/smoke_test.py confirms Season 1 stays byte-for-byte unaffected.
Two real bugs found and fixed along the way, not just features added:
- The kn00t cameo ambushes (below) didn’t mark themselves
collectedafter hitting the player, so the exact same guaranteed-fatal hit re-fired on every respawn attempt forever — a genuine permanent softlock, since both Lidl Lunch’s and Soccer’s Underworld portals sit well past their level’s own ambush point. Looked at first like a portal bug (“the caves are not entered anymore”); the portal logic itself was never broken. Fixed by marking the ambushcollectedon its first hit. Player.speedTier(River Cola pickups) used to accumulate forever within a level visit with nothing to bring it back down, snowballing run speed unbalanced. Fixed:Player.respawn()now takes an optionalresetSpeedTierflag, true only for a fatal-error respawn (energy hits 0, or falling off the world) — an ordinary level-to-level transition still carries speed tier over unchanged, exactly as before.
Also shipped alongside the mechanics, not drafted anywhere above since the ideas came up mid-build: a decorative footer-avatar parade + 60s idle auto-return on the game-complete screen, a title-screen/finale “backdrop montage” attract mode (unlocked by finishing the game, cycling through every level’s background with a caption), a ?demo=backdrops standalone showcase mode, and ?spawnX=/?equip=/?season=/?attract= debug shortcuts that made all of the above testable without scripting fragile movement timing through several minutes of a real playthrough each time.
Cross-cutting polish (built)
Three items from a working-notes pass, applying to the game as it exists today — no Season 2 mode-switch needed for any of them:
- Fall-off / timeout SFX:
Player.justFellOut(new flag, same pattern asjustJumped/justRespawned— reset at the top ofupdate(), set alongside the existingFALL_OUT_Yrespawn branch) drives a newfall_outSFX frommain.ts— a fast descending whistle ending in an impact crash, distinct fromtime_out’s slower, resigned 4-note descending sequence (added totimeOut()directly, since that function already lives inmain.ts). Two different failure feelings, not the same sound reused twice. - Underworld reverb: a
ConvolverNodefed a synthetic impulse response (decaying noise generated directly into anAudioBuffer, not a recorded sample — matching this project’s “generate everything” rule), built lazily inaudio.tsthe first time it’s needed. A newsetReverbEnabled(bool)toggle (not aplaySfxparameter — call sites shouldn’t need to know which level they’re in) is called fromloadLevelData()based onlevel.id.startsWith('underworld_'). Bug caught while wiring this up:exitUnderworld()restores level state manually rather than going throughloadLevelData()(already true ofplayMusicbefore this — see §4’s River Cola writeup for that earlier instance of the same shape of bug) — had to add the identical toggle call there too, or reverb would stay stuck on after returning to the surface. Verified via a driven test checking the toggle’s actual boolean state (not just that SFX played without erroring):falseon a surface level,trueimmediately after entering either Underworld room,falseagain after exiting back out. - Underworld patrols: a new
batobstacle (ItemDef, small dark silhouette sprite) added to both rooms past their main hazard — patrols at a fixed, non-groundY-anchored height (166–173) chosen so a standing player gets hit but crouching clears it, the same “duck under it” dodge already established for Tanzstunde. Verified via driven tests and screenshots in both rooms: the bat renders correctly, dodging/getting hit both work as designed, and the chest stays reachable past it.- Observed, not fixed (out of scope): while testing the mushroom-house lava crossing at running speed, one run got stuck oscillating right at the lava pool’s leading edge (repeated hits + knockback with no net forward progress) for several seconds before a later run cleared it cleanly. This reproduced before the bat’s own patrol zone even starts, so it isn’t something this bat introduced — it’s the pre-existing lava/knockback balance (§4, “Real balance bug found and fixed,” which narrowed the pool from
w:140tow:105for exactly this failure mode) occasionally still borderline at the reduced width, not eliminated. Flagging for whoever next touches that balance, not fixed here.
- Observed, not fixed (out of scope): while testing the mushroom-house lava crossing at running speed, one run got stuck oscillating right at the lava pool’s leading edge (repeated hits + knockback with no net forward progress) for several seconds before a later run cleared it cleanly. This reproduced before the bat’s own patrol zone even starts, so it isn’t something this bat introduced — it’s the pre-existing lava/knockback balance (§4, “Real balance bug found and fixed,” which narrowed the pool from
Tanzstunde: red-pillar rebuild (built)
A genuine floor-plan rebuild (src/game/levels/tanzstunde_s2.ts), not just a tuning pass like the drafts below. Reuses Season 1 Tanzstunde’s exact dancer/disco-ball/break-dancer placements and beat timings unchanged (still this level’s identity) — what’s new is the ground itself: instead of one continuous platform, it’s 7 “red pillar” segments (red_pillar.png, a new tile via tools/make_tiles.sh) separated by 6 gaps (23-26px each).
- Gap placement: chosen to sit in the “quiet” stretches between every hazard’s own footprint, including a waltz dancer’s full ±20px sway range — none of Season 1’s item positions had to move to make room. Sizes stay within a jump’s ~33px range at plain
WALK_SPEED(no running or speed tier required), so this is a floor-plan stakes-raiser, not a precision-jump test — that’s still Minigolf’s job. Verified via a driven test: jumping the first gap lands cleanly on the far pillar. - Falling into a gap needs no new mechanic at all: it’s just an ordinary gap in
platforms, so the player free-falls to the existingFALL_OUT_Ysafety net and respawns exactly like falling off the world anywhere else in the game — confirmed via a driven test (forced fall, watchedyincrease past 276 and land back atspawnX/spawnY). The “water” is purely a visual read on top of that, not real swim physics. - Water tile (
water.png, also new inmake_tiles.sh): a blue checkerboard, drawn as aDecorationfilling each gap’s visual area aty: 200, h: 16— within the same ~24px belowgroundYthis engine has room for without a vertical camera (the same constraint the Minigolf hidden gallery ran into).Decorationgained a newshimmer?: booleanfield;drawDecorations()applies the samectx.filter = hue-rotate(...)cycle already built forlava/waterfallindrawItems()when set, so it reads as flowing rather than a static tile. - Timer tightened 40s → 32s — the floor-plan risk is the new pressure, but the clock still needs to matter alongside it.
Watch item: timer reset (built)
New ItemDef watch (src/game/items.ts), a new ItemKind ('timer') rather than reusing 'powerup' — that kind is hardcoded to Player.collectPowerup() (grants the uervel knight), so a genuinely different effect needed its own kind rather than a defId special case bolted onto an unrelated one. Collecting one resets levelTime to 0 in main.ts’s collision loop — a fresh clock, not bonus seconds added to whatever’s left, the same “start this attempt over” feel timeOut()/a respawn already has. Plays the existing collect_speed SFX (no new sound added here — reuses what already exists rather than composing something new for a single pickup type).
Placed in three spots: Season 1 Minigolf (on the approach to pit 3, the built Keen-pipe cup), Season 1 Tanzstunde (x=770, on the approach to the VIP stash), and Season 2 Tanzstunde (same x=770 — happens to land on solid pillar ground in the rebuilt floor plan too, right before the dense VIP/disco-ball stretch). Live in Season 1 already, not gated behind Season 2 — matters most in the tightened Season 2 Tanzstunde, but the working notes this came from didn’t ask for it to be Season-2-exclusive. Verified via a driven test: levelTime at 3.57s, walked into the watch, confirmed it reset (0.78s after, matching real time elapsed since collection).
Tunables reference
None of these need new mechanics to become a Season 2 escalation — they’re existing global constants that a harder pass could just turn. Kept here as one list so a tuning pass doesn’t have to be rediscovered from source each time.
| Constant | File | Season 1 value | Controls | Turning it up/down |
|---|---|---|---|---|
HAZARD_REVEAL_DISTANCE | main.ts | 130px | How close a Minigolf pit lets you get before it opens | Smaller = less reaction time (see Minigolf draft above) |
HAZARD_HEAL_DELAY | main.ts | 2.0s | How long a healable pit stays open before resealing | Shorter = less time to walk across instead of jumping |
HURT_DURATION | player.ts | 0.35s | Stun window after a hit (movement input locked) | Longer = a hit costs more control time, not just energy |
HURT_KNOCKBACK | player.ts | 90px/s | Speed you’re flung away from whatever hit you | Lower = “stickier” hits, easier to get hit again right after |
INVULN_DURATION | player.ts | 1.0s | Post-hit grace period where further hits no-op | Shorter = harsher, punishes lingering in a hazard |
GRAVITY / JUMP_VELOCITY | player.ts | 900 / -300 | Fall acceleration / jump strength | Changes jump arc height & hang time across every jump-based level |
RUN_BASE / SPEED_TIER_BONUS | player.ts | 80 / +12 per cola | Run speed baseline and per-speed-tier bonus | Affects how forgiving a gap is at a given speed tier (§4 gap tuning) |
SNEAK_SPEED | player.ts | 28px/s | Crouch-walk speed | Slower = crouching through Tanzstunde’s rhythm costs more against its timer |
FLOAT_FALL_CAP | player.ts | 60px/s | Un-thrusted fall speed in a fly-enabled level | Higher = closer to the old ceiling-rush exploit (§4); this is the knob that fix relies on |
level.timeLimit | per-level file | Minigolf 30s, Tanzstunde 40s | Level clock (resets on any respawn) | Tighter = jumping/crouching-only-when-needed matters more, same forgiving-contract pattern as both levels already use |
The “Cross-level: per-obstacle hit tuning” draft further down promotes three of these (HURT_DURATION, HURT_KNOCKBACK, INVULN_DURATION) from global constants to optional per-ItemDef overrides — a further step past just retuning the global number.
Minigolf: shorter reveal distance (built)
Shipped as HAZARD_REVEAL_DISTANCE_S1/_S2 (main.ts) — 130px in Season 1, 85px in Season 2, selected per-frame by season. Same reveal/heal machinery, no new mechanic, just the tighter number the draft asked for.
Minigolf: rhythmic open/close hazards (built)
Instead of Season 1’s one-way reveal/heal, pits repeat a duty cycle forever — open (must jump) for some duration, closed (walk across) for another, independent of the player, like Tanzstunde’s dancer but for solid ground itself. Turns crossing into a timing puzzle rather than a one-time gotcha.
- Duty cycle, not sine: unlike the dancer’s continuous bob, this is a binary state (solid vs. gap), so model it as
openDuration/closedDuration/ a per-hazardphaseoffset (so multiple hazards in one level don’t flip in lockstep) — a square wave, notMath.cos. - Fairness rule — never yank the rug out: a naive timer would occasionally flip closed→open while the player is standing on the rect, dropping them through the ground with zero warning. The transition should defer (recheck next frame) if the player is currently overlapping that rect, only actually opening once they’ve stepped off it.
- Telegraph the flip: give it a
warnDuration(~0.5–1s) before each transition where the flag/sand flashes (reuse the alpha-flash pattern already indrawPlayer’s hurt-flicker) so a flip is never a total surprise, especially for a family audience. - Where it fits: reuses the exact
HazardDef/solids-splice machinery already built for Minigolf, just with a repeating phase instead of one-shot reveal/heal — extension, not a rewrite. Since it’s the same scene replayed, the always-visible flags from Season 1 carry over unchanged and already do the right job.
As built: exactly as drafted — HazardDef.dutyCycle (openDuration/closedDuration/phase/warnDuration), dutyCycleWantsOpen()/dutyCycleTimeUntilFlip() in main.ts, the fairness rule (never flip closed→open under a standing player), and the warn-flash telegraph. Two pits in minigolf.ts (x=420 phase 0, x=1350 phase 1.0) so they never flip in lockstep.
Minigolf: a hazard in the hidden gallery (built)
The built Minigolf hole gallery (§4) is deliberately safe — no risk to reach the food inside, the one exception to the “carved spaces should be dangerous” principle (§2). A Season 2 replay could close that gap: add a lava/waterfall-family hazard inside the existing crawlspace geometry, same spot, so the same secret that was a free bonus in Season 1 now actually costs something to raid. Cheap to build relative to the other Season 2 drafts — the room, entry, and exit already exist; this only adds a hazard inside it.
As built: lavaPuddle ItemDef (items.ts) — same lava/waterfall family, sized (w:14, h:8) to fit the gallery’s 16px crawlspace where the full-size lava/waterfall wouldn’t. Placed at the same gallery spot in minigolf.ts, Season 2 only (season2ExtraItems).
Lidl Lunch → Soccer: Soccer Shoes powerup (built)
An elaborate hidden crate in Lidl Lunch (alongside, or instead of, Season 1’s cola+schnitzel stash) holds a Soccer Shoes powerup instead of food. Persists across levels the same way player.hasKnight already does — a boolean carried on Player, untouched by respawn()/level transitions. Without it, the Soccer level’s footballGoal doesn’t count as a score (either inert, or just an ordinary food pickup) — the shoes are what let the player actually target the goal. Gives Lidl Lunch’s exploration a real cross-level payoff, and gives Soccer a prerequisite rather than an automatic pickup, in the same spirit as the uervel knight gating poison-egg defeats.
As built: soccerShoes ItemDef + Player.hasSoccerShoes/collectSoccerShoes(), a crate chained one jump beyond Lidl Lunch’s existing hidden stash (SHOES_X/SHOES_Y, lidl_lunch.ts). Soccer’s goal-scoring (both the direct-walk-up and the kicked-ball-into-goal paths) gated behind season === 2 && player.hasSoccerShoes in main.ts.
Tanzstunde: Dance Shoes powerup + dress-code guard (built)
Two more ideas in the same “equipment gates a later mechanic” family as Soccer Shoes:
- Dance Shoes: another hidden-crate powerup (Lidl Lunch again, or a Tanzstunde-specific stash). Turns what’s currently an obstacle into a win: with the shoes equipped, colliding with a Dance Partner during its downbeat dip becomes a successful move (bonus score, like the uervel knight defeating a poison egg) instead of a hit. Same
player.hasXboolean pattern as the knight and soccer shoes. - Dress-code guard: a genuinely new obstacle type — not a damage-on-touch hazard like everything else, but a hard blocker that fully stops forward progress (not just a hit) unless the player is “dressed properly.” The proper attire is tailored clothes, confirmed (§2 “Treasure trove reward progression”) to come specifically from the mushroom-house treasure trove — closing the loop between three previously-separate open threads: the treasure trove secret, the equipment-gating pattern, and a new “hard block, not just damage” obstacle mechanic that doesn’t exist anywhere yet. This is the deepest item in the reward progression (§2) — a trove reward that unlocks a mechanic in a different, later level, not just more score.
As built: Dance Shoes stash lives directly in tanzstunde_s2.ts (not season2ExtraItems — that whole file already IS Season 2’s content), gated bonus scoring on a Dance Partner’s downbeat dip via Player.hasDanceShoes/danceMove(), de-duped per beat (ItemInstance.lastDanceMoveBeat) so it can’t be farmed every frame the overlap holds. Dress-code guard: dressCodeGuard ItemDef (kind: 'obstacle', h:54 — taller than a jump’s apex, no jump-over cheese), added to moveSolids each frame only when !player.hasTailoredClothes; tailoredClothes sits in the mushroom-house treasure trove past its existing chest (underworld_mushroom_house.ts), closing the loop exactly as drafted.
Tanzstunde: player key-press dance sequence (built)
The road not taken when picking how “waltz” should manifest in Season 1 (§4 Tanzstunde enrichment went with changing the dancer’s motion instead). This is the other half: a distinct interactive moment where the player inputs a short move sequence in time with a beat, rather than just reacting to a hazard.
- Move vocabulary: reuses existing input primitives as-is — crouch, left, right, jump. No new controls. A “combo” is just an ordered list, e.g.
['crouch', 'left', 'crouch', 'right'](a box-step). - Timing/grading: a beat clock (same pattern as the waltz motion’s
beatT) advances through the sequence; each input is graded by how close it lands to its beat window — e.g. “perfect” within ±100ms, “good” within ±250ms, “miss” outside that. Miss = no bonus, no punishment — keeps it a skill reward, not a hard gate, consistent with this being a family game. - UI: a prompt showing the next 1-2 expected inputs and a moving beat-indicator, similar in spirit to any rhythm-game arrow prompt, rendered the same way
drawGoalFanfare’s transient banner already is (a temporary overlay, not a permanent HUD element). - Where it fits: a natural capstone for a Tanzstunde replay — could replace or sit alongside the finale dance number as the level’s actual final beat, giving the boss encounter a genuine “you danced well enough to earn this” lead-in rather than just another hazard to survive.
- Engine shape: a new transient
state(like'won'/'gameComplete'already are) that pauses normal hazard/item updates while active, reads the sameInputmethods already used everywhere else, and resolves back to'playing'when the sequence ends.
As built: a new 'dancing' state, triggered once (danceSequenceTriggered, reset per level visit not per respawn) at x=1805 in tanzstunde_s2.ts — just past the finale trio, before bossX: 1860. Combo is exactly the drafted box-step (['crouch','left','crouch','right']), timing 1.0s lead-in / 0.7s between beats / ±100ms perfect / ±250ms good, miss genuinely does nothing (no bonus, no punishment — verified by a driven test that lets the whole combo time out untouched and confirms the player can still go on and reach the boss). New edge-triggered Input.leftPressed()/rightPressed()/crouchPressed() (grading needs a discrete press, not the held-state methods movement normally uses). One judgment call beyond the draft: player position/velocity are frozen during the sequence rather than running live physics, since the trigger sits only ~10px before a real water gap and a held direction satisfying beat timing must not double as real movement into it.
Above the Clouds: wind gusts + a finale gauntlet (built)
The last of the 5 levels without its own Season 2 idea. Season 1 already fixed the ceiling-rush exploit (float-fall cap + altitude-spread airplanes, §4), but unlike Minigolf (duty-cycle pits), Tanzstunde (tango, key-press dance), this level has no signature escalation of its own yet. Wind is the natural fit — the one level built around sustained flight, and turbulence is the aviation-flavored equivalent of Minigolf’s timing puzzle or Tanzstunde’s rhythm challenge.
- Wind gust zones: a horizontal push force applied within specific x-ranges (or tied to proximity to an airplane, as “wake turbulence”), independent of player input — adds to
vxwhile inside the zone. Forces active correction (holding against the wind) rather than the level staying a purely vertical-thrust puzzle. - Telegraphed, not sudden: same principle as the duty-cycle hazard’s
warnDurationdraft (Minigolf, above) — a visual cue (a windsock decoration, particle streaks) before a gust zone, so fighting it reads as “manage this” rather than a gotcha, consistent with this being a family game. - Finale gauntlet: mirrors Lidl Lunch’s checkout-dash and Tanzstunde’s finale dance number — a tight cluster of airplanes plus a gust zone in the last stretch before EvilOriginalkn00t, this level’s one difficulty crescendo.
- Where it fits: a zone definition (a rect + a push vector) read in the same per-frame item-update loop that already handles patrol/bob/waltz motion — extension of the existing pattern, not a new subsystem.
As built: WindZone type (level.ts, {...Rect, pushX}) + season2ExtraWindZones on LevelData; a per-frame windPush accumulator in main.ts sums every overlapping zone’s pushX and feeds it into a new windPush parameter on Player.update(). Finale zone in clouds.ts: x=1700-1820, pushX: -35 (against the boss direction, forcing active correction), full playable height so altitude can’t dodge it, plus 2 extra tightly-clustered airplanes on top of Season 1’s own patrol — the difficulty-crescendo gauntlet the draft asked for. Telegraphed via a windsock decoration (always visible, Season 1 too) and animated streak-line rendering (drawWindZones()) only while the player is actually inside a zone.
Cross-level: per-obstacle hit tuning (built)
Right now HURT_DURATION, HURT_KNOCKBACK, and INVULN_DURATION (src/game/player.ts) — the knockback distance, the stun window where movement input is locked, and the post-hit invulnerability that backstops it — are global constants. Every obstacle in every level punishes a hit identically today; there’s no way for one hazard to hit harder or stun longer than another.
Season 2 could make these per-ItemDef instead: optional knockback / hurtDuration / invulnDuration fields that takeHit() reads from whichever item caused the hit, falling back to the current global values when an obstacle doesn’t set them. Most Season 1 obstacles would need no changes at all — they’d just keep using the defaults.
- Where it’d matter: a Season 2 boss attack or an escalated hazard that should hit harder or stun longer than an ordinary football or dance partner, without inventing a whole new hazard class just to get a heavier hit.
- Engine shape:
takeHit(fromX, damage, opts?)readsopts.knockback ?? HURT_KNOCKBACK(and similarly for the other two), and the item-collision loop inmain.tspassesdef.knockback/def.hurtDuration/def.invulnDuration(all optional onItemDef) at the call site instead of nothing. - Distinct from a “sticky hazard” (lava/quicksand-style continuous tick damage that ignores
invulnTimeraltogether) — that’s a genuinely different mechanism, not a tuned version of this one. This draft only varies the numbers per obstacle; the underlying knockback-then-invulnerability model stays the same for everything.
As built: exactly as drafted — optional knockback?/hurtDuration?/invulnDuration? on ItemDef, takeHit(fromX, damage, opts?) falls back to the global constants when unset. Used by the kn00t cameo ambushes below (heavier, guaranteed-fatal hits) without needing a new hazard class.
kn00t cameo ambushes: harmless sight gags turn fatal on a replay (built, not originally drafted)
Not in the original Season 2 notes — came up mid-build as a natural use of the equipment-gating/per-obstacle-tuning machinery above. Season 1 has several purely decorative kn00t cameos (Divingkn00t in Lidl Lunch’s freezer door, Cyberkn00t floating in Above the Clouds) explicitly described as “no functional effect” sight gags. On a Season 2 replay, the same cameo — same sprite, same position — becomes a real, guaranteed-fatal (value:100) obstacle: kn00tFreezerAmbush, kn00tCloudsAmbush, plus two new cameos built with the same treatment from the start (kn00tMinigolfAmbush, kn00tSoccerAmbush, since Minigolf and Soccer had none in Season 1).
Bug found and fixed 2026-08-07: none of the four originally set item.collected = true after hitting the player, so the exact same guaranteed-fatal hit re-fired on every respawn attempt forever — a permanent softlock (see this section’s opening summary). Fixed by marking the ambush collected on its first hit, restoring the intended “one surprise per level visit” contract.
River Cola speed-tier reset on fatal error (built, not originally drafted)
Also not in the original notes — a fix that came directly from a real playtest observation: River Cola speed-tier boosts (Player.speedTier) accumulated forever within a level visit with nothing to bring them back down, snowballing run speed unbalanced over a long session. Player.respawn() gained an optional resetSpeedTier parameter, true only at the two genuinely fatal-error call sites (energy hits 0; falling below FALL_OUT_Y) — loadLevelData()’s own unconditional respawn call (every level load, advanceLevel(), Underworld entry) stays default false, so speed tier still carries over normal progression exactly as before.
Cross-cutting: the hidden-level backbone Season 2 leans on
All three of Season 1’s secret-area portals are now fully working end to end: the Minigolf hole (§4, a shallow hint), River Cola’s crate stack (§4, a fully working Underworld portal), and the mushroom-house door (§4, likewise fully working — the walk-in portal-exit-loop bug found while building it was fixed generally in exitUnderworld(), so both portals round-trip correctly). The dress-code guard draft above depends on the mushroom-house trove handing out tailored clothes — that dependency is now unblocked, since the trove itself works and can only be opened once (PortalDef.used, §4). Soccer Shoes and Dance Shoes don’t need a new hidden level (they’re drafted as hidden crates within existing levels, Lidl Lunch/Tanzstunde), so nothing engine-side is left blocking any of Season 2’s equipment-gating drafts — they’re all just level-content work now.
As built: confirmed — both portals verified round-trip cleanly end to end via driven Playwright tests (not just manual spot-checks), including the softlock investigation above, which specifically proved the portal logic itself was never the problem.
Presentation polish + dev tooling (built, not drafted anywhere above)
A few things that came up mid-build, outside any single level’s escalation:
- Footer avatar parade + idle auto-return: the game-complete/scoreboard screen gained a purely decorative footer of all 4 chooser avatars drifting left-to-right with a small bob (translate+bob, not real walk-cycle frames), and a 60s idle timer that auto-returns to the title screen (same reset
quitToTitle()already does) if nobody presses jump — an arcade-style attract-mode fallback for a screen that would otherwise sit there forever. - Backdrop montage + attract mode + demo showcase: a shared
drawBackdropSequence()helper crossfades through a sequence of levels’ own backgrounds with a caption, reused three ways — the grand-finale (true game-complete, not the Season 1 checkpoint) screen gets a “everywhere Pixel has been” epilogue reel instead of a flat dark background; the title screen gets the same treatment as an attract mode, unlocked (persisted vialocalStorage) the first time the game is actually finished; and a standalone?demo=backdropsmode cycles through all 8 backdrops in the game (5 Season 1 levels + Tanzstunde S2 + both Underworld rooms) with no HUD, for showing the background art off on its own. - Debug shortcuts that made all of the above testable:
?level=/?season=2(reach any level/season without a full playthrough),?spawnX=(spawn at a specific position — one-shot, cleared after first use so it can’t leak into a laterenterUnderworld()call and misplace the player),?equip=(grant Season 2 equipment without the real pickup detour),?attract=1(preview the title attract mode without actually finishing the game first). - TV dev-testing convenience:
vite.config.tsgained atestRedirect()plugin that 302-redirects the TV’s one long-standing bookmark (http://pad.local:5175/) to whatever’s currently being focused on, edited on the dev machine rather than requiring a new URL typed on the TV remote each time — andnpm run dev:playtest(HMR off) alongside plainnpm run dev(HMR on), so a live playtest session doesn’t get reset mid-run every time a file saves.
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.
Next Up
What’s left, now that Season 1 and Season 2 are both fully built. PLAN.md stays as the build history (why every shipped mechanic exists, exact as-built detail) — this file is where the next things live instead of getting bolted onto the end of an already-huge document.
RESEARCH TASK — architecture + tooling review — DONE, awaiting decision
Written up 2026-08-07, researched same day (4 parallel agents, read-only). Findings +
a concrete proposal are in ARCHITECTURE-REVIEW-2026-08.md (repo root) — read that
before touching any of this. Original framing kept below for reference, but the actual
next step is: discuss the proposal with Torsten, then implement whatever’s agreed, not
re-research from scratch.
One artifact already exists from the research pass, uncommitted: a proof-of-concept
pure-function extraction + Vitest test in src/game/player.ts /
src/game/player.test.ts (plus vitest added to package.json) — see the doc’s §4
for what it demonstrates. Review and accept/tweak/discard before building on it.
Original task framing (2026-08-07)
Written up 2026-08-07 so a fresh session (no prior conversation) has everything needed to pick this up cold. This is a research/analysis task — read, dig in, come back with findings and a concrete proposal. Don’t start implementing a redesign until that proposal’s been discussed.
Why this is happening now: building Season 2 took much longer than building Season 1 did, and a single afternoon’s round of post-playthrough bugfixes (a portal bug, an unreachable pickup, HUD gaps, content tuning) felt disproportionately slow too, largely because verifying each fix meant a full Playwright browser round-trip (dev server + headless Chromium + scripted keypresses + screenshots/state polling), sometimes several iterations to get a single platform’s jump geometry right. Torsten’s own framing: “I rather invest a few hours in an architectural redesign than wait 30 minutes each for a bunch of bugfixes.” Three concrete things to look at:
1. The Season 1/2 split architecture may be structurally wrong
Read MECHANICS.md’s “Season 1 vs. Season 2” section first for how it works
today: most levels reuse their exact Season 1 LevelData object for Season
2, and escalations are spliced in via optional season2Extra* fields
(season2ExtraItems/season2ExtraHazards/season2ExtraPlatforms/
season2ExtraWindZones), read by loadLevelData()/resetHazards() in
src/main.ts only when season === 2. In practice this meant almost
every Season 2 mechanic touched main.ts directly, scattered across many
if (season === 2) checks at different call sites (the hazard-reveal
distance, the wind-push accumulation, the goal-scoring gate, the ambush
items, the music-layer lookup, and more) rather than living somewhere
Season-2-specific and self-contained. It also caused a real bug
found via a full playthrough 2026-08-07: PortalDef.used is mutable state
on the shared LevelData object, so finding an Underworld portal once in
Season 1 permanently marked it used for Season 2 too — nobody designed
that, it just fell out of sharing state across an accidental object
reference. (Fixed for now with a targeted reset in startSeason2(), but
that’s a patch, not evidence the architecture is right.)
The actual question: is “one LevelData object, conditionally
mutated/spliced based on a season variable read at dozens of call sites in
one giant main.ts” the wrong shape? Alternatives worth evaluating:
- Each level exposes its own explicit Season 1 and Season 2
LevelData(even if Season 2 mostly just spreads Season 1’s and overrides a few fields) — makes the diff between seasons readable in the level file itself, not reconstructed by greppingmain.tsforseason2Extra*andseason === 2. - A small “level variant” or “modifier” abstraction the engine understands
natively, so adding a Season 3 (or a difficulty variant, or a remix mode)
doesn’t mean editing
main.tsin N more places. - Keep the current shape but build real helpers that collect all the
season-conditional logic into one place per concern, so
main.tsstops accumulating scatteredif (season === 2)checks one bugfix at a time.
Don’t assume the fix is obvious - actually look at how main.ts grew this
session (it’s the single largest, most-touched file by far) and form a real
opinion about whether the shape of the split is the problem, not just its
current size.
2. Look at latticexyz’s MUD and OPCraft for architecture ideas
Clone these locally and read the code — ignore/skip anything Ethereum/ blockchain-specific (the on-chain state sync, contracts, wallet stuff), pixelsp33d has no on-chain component and none is planned. What’s actually worth mining: how MUD structures game state as an ECS (Entity-Component- System) — components, systems, world — and how OPCraft (a real shipped game built on MUD) organizes its game logic, client/rendering split, and dev tooling on top of that. Repos:
https://github.com/latticexyz/mud- OPCraft’s repo (search from the MUD org/docs if it’s not obviously named the same - it was Lattice’s own voxel-game demo built on MUD)
What to actually extract, concretely:
- Does an ECS-shaped core (entities = numeric/opaque IDs, components = plain
data keyed by entity, systems = pure functions operating on component
queries) look like a better fit for pixelsp33d’s own
ItemInstance/HazardDef/Playermodel than what exists today? Would it make a “Season 2 adds awindPushcomponent to some entities” change local and additive instead of a newif (season === 2)branch inmain.ts? - How do they structure “systems” so game logic is testable independent of rendering? (Directly relevant to the testing question below.)
- Any dev-tooling/hot-reload/debug-inspector patterns worth stealing
regardless of the ECS question - e.g. OPCraft likely has some kind of
live world inspector, which is conceptually similar to pixelsp33d’s own
ad-hoc
window.__ps33dDebughook that’s grown organically this session and could probably be more principled.
Come back with: what’s genuinely reusable (as inspiration/pattern, not literal code - MUD’s the wrong language/runtime shape for a Vite/vanilla-TS browser game), what’s blockchain-specific cruft to ignore, and a concrete recommendation on whether an ECS-shaped rewrite of the core game-state model is worth it here.
3. When does pixelsp33d outgrow Canvas2D?
Not urgent, but worth having a real answer instead of a vague feeling.
Think through: what are the actual signals that would justify moving off a
hand-rolled CanvasRenderingContext2D draw loop (current approach - no
engine, no WebGL) - entity count? layered-effect complexity (parallax +
shimmer + crossfades are already hand-rolled per-effect)? mobile
performance once touch controls exist? Actually check: startLoop()
(src/engine/ - find it) and how many draw calls a busy scene (e.g.
Tanzstunde S2’s finale, ~5 dancers + disco balls + water shimmer + HUD)
costs per frame today, and whether there’s headroom or it’s already close
to a budget. Propose a decision framework (a set of concrete thresholds -
not “rewrite it now,” not “never think about it again”) and, if relevant,
name a specific lightweight next step (a thin WebGL sprite-batching layer?
a real 2D lib like PixiJS? something else?) rather than jumping straight to
“use a full game engine.”
4. Testing strategy - Playwright doesn’t scale, what do real studios do?
The concrete pain: verifying the Dance Shoes platform fix this session took
several rounds of “edit level file → start dev server → launch headless
Chromium → script keypresses with hand-tuned millisecond delays → read
window.__ps33dDebug state → adjust numbers → repeat” - each round-trip
slow, and fundamentally testing the rendered browser game, not the
underlying game logic. This does not scale as the mechanics catalog grows.
Research what real game studios actually use for automated gameplay testing (distinct from web E2E testing, which is what Playwright is built for) - likely candidates to look into: deterministic simulation / “headless mode” that runs game logic without any rendering at all, replay-based testing (record real input sequences, replay and assert on resulting state), unit-testing pure game-logic functions in isolation (collision math, hazard state machines, item-pickup resolution) without a browser at all, snapshot/property-based testing for level geometry invariants (e.g. “every jump-required gap must be ≤ max jump range” - would have caught the Dance Shoes bug at level-definition time, not via manual playtesting).
Concrete question to answer: could pixelsp33d’s core game logic (collision resolution, hazard duty-cycles, item-pickup rules, the dance sequence’s grading math) be refactored to run and be asserted on in plain Node, with zero browser/Playwright/canvas involved - fast, synchronous unit tests colocated with the mechanic they test, the same PR/commit that adds a mechanic also adding a targeted test for it? Playwright would then be reserved for genuine end-to-end/rendering/input-integration smoke checks (a handful, not the primary verification method), not the only tool in the box. Come back with a concrete recommendation and, ideally, one small proof-of-concept (e.g. a pure function extracted from the jump-height math, unit-tested directly) demonstrating the shape of the answer.
Also worth considering as an outcome of this research, not just a code
change: offloading test-driving to dedicated hardware. Torsten has a
homelab/fleet (see tracker’s reference_fleet_naming/reference_home_tailnet_hosts
memory if this session has access to ~/txt/tracker/’s memory - otherwise
just ask him what’s available) - a spare box could run the Playwright/
headless-browser suite (or a future replay-based one) continuously or on a
schedule, off the main dev machine entirely, rather than every verification
loop competing for the same laptop that’s also running the dev server and
the editor. Whether that’s worth setting up depends partly on the answer to
the “does this even need a browser” question above - a pure-Node unit-test
suite is cheap enough to run anywhere/constantly; a full Playwright+Chromium
suite is exactly the kind of thing that benefits from living on its own box
instead of eating local resources during active development.
Done, 2026-08-07: tools/fleet_test.sh (local driver, run from the
laptop) + tools/remote_test_runner.sh (runs on the remote box) implement
this against trullala. rsync ships the current working tree over (no git
remote exists between this repo and trullala - verified, this repo is
local-only); the remote side runs npm ci → tsc --noEmit → vitest run
→ starts the dev server → both Playwright smoke suites → tears the server
down, inside the shared trullala-tasks tmux session per tracker’s fleet
convention. Verified end-to-end live: bootstrapped python3-venv +
Playwright + Chromium on trullala (none of that was present before), ran
the full pipeline twice, all 6 steps PASS both times. Result comes back to
tools/.fleet-results/.remote-test-status + .remote-test-log on the
laptop, no tmux attach required to check.
Mobile touch controls
PLAN.md’s P7 “Ship” phase always scoped this as the last thing before
deployment: a static dist/ build plus on-screen touch controls, everything
else (hosting, domain) left as a separate decision. Not started. The engine
already reads through one Input class (src/engine/input.ts) with keyboard
and gamepad both normalized to the same held/edge-triggered methods — touch
would be a third source feeding the same interface, not a parallel input
path.
Hosting choice for pixelsp33d.de
Done 2026-08-10 — full “as built” record moved to PLAN.md §7 (“Hosting, docs,
and public launch”) since it’s now frozen build history, not live work. Short
version: the game is live at https://pixelsp33d.de/ (Cloudflare Pages), the docs
site is public at https://docs.pixelsp33d.de/ and privately at
https://preview.pixelsp33d.de/ (login-gated, for staging doc changes before
promoting them), and trullala (the homelab box) serves a private LAN-only playtest
copy at http://192.168.178.42:8095/.
Idea, not built (2026-08-10): extend the existing TEST_REDIRECT_TARGET
redirect trick (vite.config.ts’s testRedirect() plugin, see
memory/reference_tv_testing.md) to make switching which host the TV’s one
bookmark points at (e.g. pad’s dev server vs. trullala’s static demo) as
easy as switching debug targets already is - so Torsten doesn’t have to
re-bookmark or hand-edit the IP on the TV remote every time. Two variants
floated, either could work, neither built:
- Same as today - Claude edits a constant/config server-side and restarts whichever server needs to pick it up.
- Self-serve via an in-game OSD, e.g. an “extras” menu item - Torsten picks the target himself from a list on the TV (gamepad-driven), no laptop-side edit needed at all. Would need its own small redirect/registry service (or piggyback on whichever host the OSD itself is served from) since a static build has no server-side plugin to update at runtime.
atproto score/trophy lexicons (de.pixelsp33d.*)
Conceptual only, never implemented. Pentaract’s own score/achievement
lexicons don’t exist yet upstream — de.pixelsp33d.* was always meant as a
stopgap, and may need migrating if/when upstream lands real equivalents
rather than being a permanent fixture.
Done 2026-08-10, unrelated to the lexicon question itself: the project
picked up a real ATProto identity, pixelsp33d.eurosky.social (a bsky
handle on the eurosky.social PDS) — a placeholder, intended to move to a
pixelsp33d.de-based DNS handle later now that the zone exists. See
ATPROTO for the fuller brainstorm this connects to.
Narration audio (Moritz)
Not blocking — the game is caption-first already. Distinct from background music (built, procedural, already playing under everything): this is spoken-word per-level commentary, still unrecorded. Waiting on Moritz, not on any engine work.
Tanzstunde dance sequence: relocate + tie to a chest, feeding the finale
Real design idea from a full playthrough (2026-08-07), not implemented yet. Torsten’s own framing: “the dance button action should move to the start of the level or even better to the underworld of the tanzstunde portal. The idea is that the dance move opens the chest. The chests content are the lead to the finale and also the rickroll later.”
What this actually proposes, spelled out since it’s a few linked ideas at once:
- Move the dance sequence’s trigger (currently x=1805 in
tanzstunde_s2.ts, right before the boss) somewhere earlier/different - either the start of the level, or (Torsten’s preferred option) a new Underworld sub-level reached via a Tanzstunde portal that doesn’t exist yet - Tanzstunde currently has no secret-portal/Underworld connection at all (only Lidl Lunch and Soccer do, perMECHANICS.md). Building this means a genuinely new portal + a new Underworld room, not just moving an x-coordinate. - Change what a successful dance does: instead of the current small
per-step energy bonus (
Player.danceSequenceStep()), a good dance opens a chest. - That chest’s contents become the lead-in to the game’s actual finale, and also connect to the rickroll gag below - i.e. this and the rickroll gag are the same underlying “grand finale” design thread, not two separate features.
This is a real reward-progression redesign (touches the dance sequence, the portal/Underworld system, the chest reward, and the finale screen all at once) - worth designing deliberately with Torsten before building, not implementing from a one-line paraphrase.
Decided 2026-08-08 (as envisioned by uervel): the dance-sequence chest is the rickroll trove — one object, not two. Tanzstunde’s dance QTE teaches the player a specific input sequence (Simon-Says style, not just a generic energy-bonus minigame); that same learned sequence is what has to be correctly repeated at the trove to open it and trigger the rickroll episode. “Learned, then repeated” is the actual mechanic connecting the two NEXT.md sections below.
Fully resolved 2026-08-08 (see the rickroll gag section for the full
rationale on each): the trove lives in a new Clouds secret room, “The
Upperworld” (Clouds is the only level reachable after Tanzstunde in the
fixed level order, so it’s a real recall gap, not an immediate repeat);
recall keeps the beat-bar/rhythm timing help but drops the move labels
(a real memory test without becoming guess-the-combo); and the payoff
fires mid-run, as soon as the player reaches Clouds — not gated behind
hasCompletedGame, which would make it structurally unreachable during
the run that’s supposed to lead into it. Implemented and merged to
main, 2026-08-08 — since playtested, with several follow-up fixes
(portal visibility/positioning, dance-lesson demo+loop redesign, wind
gauntlet) also merged; see PLAYTEST-FINDINGS.md for the full record.
The rickroll gag (was ROLL.txt, folded in here 2026-08-08 — nothing lost, see below)
Unbuilt. A treasure trove (reusing a “known good from an earlier level” prop) unlocked by an alternate-button sequence with some real timing precision to it — a visible lever/gear turning on each correct press so the unlock mechanic reads clearly, not a blind guess-the-combo. Payoff: an 8-bit rickroll animation + music sting.
Licensing note (from the original ROLL.txt): Torsten flagged needing
“royalty free licenced” audio for the sting, not the real song outright —
the bytebeat_*.wav sketches below are one way to sidestep that entirely
(a deliberately broken-sounding bytebeat reinterpretation of the joke,
not a needle-drop), but if a more literal audio cue is ever wanted, it
still needs a cleared/royalty-free source, not the original recording.
Torsten’s own framing ties this to the game’s eventual grand finale, not
a standalone easter egg dropped anywhere convenient — “will come back to
this for the grand finale, interludes and ROLL’in.” The bytebeat_*.wav
sketches in assets/gen/music/season3_exploratory/ were generated with this
specifically in mind (deliberately weird/broken-sounding is the joke) — see
that directory’s own README.md.
Decided 2026-08-08: this trove is the Tanzstunde dance-sequence chest (see the section above) — not a separate pickup. The “alternate-button sequence” required to open it is specifically the sequence the dance QTE already taught the player; opening it is passing a recall test, not guessing a combo.
Fully resolved 2026-08-08, closing out the three sub-questions this left open:
- Where the trove sits: Clouds (“The Upperworld”, a new portal-gated secret room), not next to Tanzstunde. Clouds is the only level reachable after Tanzstunde in the fixed, no-backtracking level order, so this is a real recall gap — the physical-distance idea from ROLL.txt’s original “known good from an earlier level” framing survives, just resolved to a specific place rather than “revised.”
- How recall is tested: the middle ground — keep
drawDanceSequenceOverlay()’s beat bar (rhythm/timing help) but drop the move labels. Tests whether the player remembers what to press without also demanding blind reflex-precision on top of memory (DANCE_COMBOis only 4 moves; fully blind risked reading as guess-the-combo rather than a fair callback). - When it pays off: mid-run, reachable as soon as the player reaches
Clouds — not gated behind
hasCompletedGame. That flag only gets set after finishing the game, which would make the trove unreachable during the very run it’s meant to lead into (Clouds is the last level; Season 2’sgameCompletefires at its boss, after where the Upperworld portal sits). The Upperworld’s backdrop is added toDEMO_BACKDROPSso it shows up in the existinggameCompletecrossfade montage as an echo, at no extra engine cost.
Implemented and merged to main, 2026-08-08 — see the section above
for the paired teach-side build (Tanzstunde’s own new Underworld room).
The rickroll animation/music payoff itself is also now built and merged,
2026-08-08 (same day, later session) — a generic state === 'interlude'
mechanism (title card + 2-frame animated scene, blocks input) serves both
the 6-7 gag (below) and the real rickroll payoff: a disco backdrop (disco
ball, spotlight beams, the existing Tanzstunde dancefloor tile) + a
procedural kn00t-in-trenchcoat sprite, with the existing bytebeat_1.wav
(from season3_exploratory/) graduated into a real rickroll MusicName.
triggerRickrollStub() is no longer just a flag-flip stub — it starts the
real interlude. Built in a worktree concurrently with two research docs
below; see “Reconciling the build” for what that surfaced and what’s still
open.
Reconciling the build against the concurrent research docs (2026-08-08)
The rickroll/6-7 build ran in its own worktree at the same time as
SIXSEVEN-GAG-2026-08.md and RICKROLL-AUDIO-2026-08.md were being
researched — neither saw the other’s output. Comparing after the fact
surfaced six divergences; Torsten decided all six in one pass:
- kn00t_67 sprite resolution — keep as built (16×16). The doc found the source gif’s native grid is actually 20×20 (not 16×16 like the rest of the kn00t family), which would be more faithful — but 16×16 keeps it visually consistent with every other kn00t variant, and the sprite is already built and tested. No further action.
- 6-7 gag backdrop — keep as built (flat black + spotlight). The doc
proposed holding a real level backdrop behind it instead (via
drawBackdropSequence()). The flat treatment reads as a clean, isolated “stage” moment, closer to a comic-strip cutaway. No further action. - Rickroll’s
kn00t67_stingSFX tone — recompose as silly/kazoo-ish. Built triumphant (reads as “you accomplished something”); both the original design intent andRICKROLL-AUDIO-2026-08.mdwanted something that reads as a joke instead. Action needed: rework thekn00t67_stingblock intools/make_sfx.py(~line 194 — currently an explicit “ta-da”-style rising run + landing flourish per its own comment) into a deliberately silly/kazoo-ish register instead, same pulse/noise/envelope primitives, different character. Re-renderassets/gen/sfx/kn00t67_sting.wavafter. - Caption text (“MEANWHILE…”) — keep as built. Not contested;
matches
docs/src/story.md’s existing “Meanwhile…” interlude convention directly. No further action. - Splice architecture — keep as built (defer the level load until the interlude resolves, not load-then-overlay). Purely internal; the doc’s alternate approach produces the same visible result. Not worth reworking for no user-facing difference. No further action.
- Richer original rickroll audio track — build it now. The built
payoff only wires the existing zero-risk
bytebeat_1.wav.RICKROLL-AUDIO-2026-08.md’s compositional brief (real melody/chords composed fresh, evoking ~113 BPM/B♭ minor/SAW-era instrumentation without copying it — see that doc’s “line drawn” section for exactly what’s safe vs. not) was recommended as an additional tier, not a replacement. Action needed: compose the new track per that brief, fittingtools/make_music.py’s existing primitives (render_voice,pulse/triangle,kick/hihat/clap) via a newmake_rickroll()- style function — this is real composing work (fresh melody + chords), not parameter tuning. Once rendered, wire it in alongside (not instead of) the existing bytebeat track — exact selection mechanism (replace at therickrollMusicName, or a second name picked between) still open, decide when actually building this.
Remaining actionable work from this thread: items 3 and 6 above (a sting re-render + a new composed track) — everything else is closed with no further changes needed.
Items 3 and 6 done, 2026-08-08. kn00t67_sting recomposed in
tools/make_sfx.py into the silly/kazoo-ish register (a nasal duty
cycle, a comedic “wah-wah-wah” triple toot, a droopy downward landing
instead of an ascending flourish) and re-rendered.
Item 6 ended up producing three rickroll tracks rather than one,
after Torsten reviewed the brief-compliant original and then explicitly
chose to also add a literal reproduction — see
RICKROLL-AUDIO-2026-08.md section 6 for the full decision record
(stated rationale: relying on meme-culture/parody norms for the legal
footing, not on avoidance-by-composition):
'rickroll'— the original zero-risk bytebeat track (unchanged).'rickroll_composed'(make_rickroll_composed()) — an original synth- pop pastiche per the brief in section 3: 113 BPM, Bb minor, fresh melody/chords, no vocal.'rickroll_hooktheory'(make_rickroll_hooktheory()) — a literal transcription of the real chorus’s melody and ii-V-iii-vi progression, sourced from Hooktheory data pasted directly into section 6 (the page itself 403’s automated fetching).
main.ts’s rickrollInterludeConfig() now picks one of the three
uniformly at random each time the interlude fires (RICKROLL_TRACKS),
and sizes sceneDurationS to that specific track’s real decoded length
via a new getMusicDuration() export in audio.ts — the three tracks
run 12.0s/21.24s/16.84s respectively, so a fixed duration (this config’s
shape before the third track existed) would have cut the two longer ones
off mid-phrase. tools/smoke_test_season2.py’s rickroll-interlude test
had its poll timeout widened accordingly (16s → 26s) to cover the
longest possible pick.
The gameComplete/leaderboard screen also picked up a small callback:
advanceLevel() plays 'rickroll_composed' there instead of the
player’s usual per-player bytebeat, but only for a run where
rickrollStubTriggered is true (the trove was actually found) —
everyone else’s leaderboard music is unchanged.
Verified: tsc --noEmit, vitest run (23 passed), and both Playwright
smoke suites green across several repeated runs (to exercise different
random track picks).
Also fixed in the same pass (unrelated bug, caught by inspection):
drawRickrollScene’s caption always read “kn00t found the vibe.” even
when the current player was pixelsp33d/uervel/Mama — kn00t is
just one of the four playable characters (scoreboard.ts’s
PLAYER_NAMES), not a fixed narrator. Now interpolates currentPlayer.
The kn00t_67 cameo sprite itself is unrelated and untouched (that’s a
separate NPC, not the player character).
Follow-up, same day: rickroll_hooktheory is a gag written
specifically for pixelsp33d — that character now always gets it
deterministically (rickrollInterludeConfig’s track check), no roll
of the dice; every other character still gets the random pick across
all three tracks. Separately, kn00t67_sting’s original pre-rework
“triumphant ta-da” version (recovered from git history, commit
111b2f8^) is back as kn00t67_sting_tada, wired alongside the kazoo
rework as a random pick in sixSevenInterludeConfig rather than one
replacing the other. Both changes exposed a real regression:
smoke_test_season2.py’s test_upperworld_recall_grants_chest_and_ rickroll_stub has its own separate _poll_until_state(..., max_ms= 16000) call (distinct from the dedicated rickroll-interlude test’s,
already widened earlier) that I’d missed — since that test’s
currentPlayer defaults to pixelsp33d (never goes through the
chooser), it now always draws rickroll_hooktheory’s ~18.4s total wait,
which blew past the old 16s cap. Widened to 26s; re-verified green
across multiple runs against a live dev server afterward.
Surge test, same day: both smoke suites gained a --player flag
(smoke_test.py/smoke_test_season2.py) that drives the real in-game
chooser (ArrowRight × N + Space, matching PLAYER_NAMES’s order -
not a debug/URL shortcut) before running any test, so
currentPlayer-dependent behavior gets exercised as an actual player
selecting that character would trigger it. tools/surge_test.sh runs
both suites once per one of the four characters against an
already-running dev server, resetting the scoreboard first via a new
tools/reset_scoreboard.py (a real page visit to ?resetScoreboard=1,
same debug param the OSD’s own reset uses - not direct localStorage
surgery). All 4 characters × both suites passed clean.
Idea for later (not being built now): the headless-Playwright method used for these smoke tests could double as a screenshot/ screencast capture pipeline for promo material, or even a “replay” section in the OSD extras — Torsten floated this while watching a smoke-test run and realizing it wasn’t a visible playthrough. Purely a someday idea, no design work done.
Season 3
Doesn’t exist as a game structure yet — no decision made on what a third
pass over these scenes would even mean mechanically (harder again, like
Season 1→2? A genuinely different angle per level? Something else?).
tools/explore_season3_music.py generated a first batch of candidate
textures (punchy synth, heavier atmo, three bytebeat one-liners) as pure R&D,
not tied to any committed direction — a menu to pick from once there’s an
actual Season 3 idea to score, not a plan in itself.
Small, low-priority housekeeping
- Discord CDN links in
ASSETS.txtare signed and will expire — harmless today since everything’s already pulled intoassets/src/, only matters if a re-download is ever needed.
Audio & SFX Design
pixelsp33d — audio plan (new file, 2026-08-02 — replaces the earlier note, which suggested using real audio clips for “I am the boss” lines. Dropped.
Status
[done] Gameplay SFX — procedurally synthesized from scratch, no source material, no licensing to track. tools/make_sfx.py generates 14 chiptune-style WAVs into assets/gen/sfx/, wired into the engine via src/engine/audio.ts and triggered from src/main.ts:
- jump - rising pulse-wave sweep, on takeoff
- land - low thud + noise burst, on landing
- collect - two-note blip, ordinary food pickups
- collect_dropchicken - light double-cluck, the harmless half
of the egg-dish pair (Lidl Lunch)
- collect_speed - ascending 3-note run, River Cola / speed tier
- collect_powerup - richer chime, uervel knight
- hurt - descending buzz + crunch, obstacle hit
- defeat_enemy - ascending zap, knight defeats a poison egg
- goal - whistle trill + fanfare, Soccer's footballGoal
(walked up to directly, or a well-aimed kick)
- kick - snappy impact, Soccer's soccerBall
- portal_enter - descending warp sweep, stepping into the Underworld
- portal_exit - ascending warp sweep (mirror of the above), climbing back out
- chest_open - wooden creak + a bright sparkle, Underworld treasure trove
- level_complete - short victory fanfare, boss reached
Verified in a headless-Chrome playthrough: SFX preload/decode and
every trigger path run with zero console errors.
[done] Level background music — also procedurally synthesized, same “no source material, no licensing” reasoning as the SFX above. The original plan below (source a CC0/CC-BY track per level) turned out unnecessary: tools/make_music.py builds a real step-sequencer (note-name parsing, chord/bass/melody voices, optional kick/hihat) on the same numpy pulse/triangle/envelope approach as the SFX file, generating one genuine ~10-25s loop per level into assets/gen/music/, distinct in key/tempo/ instrumentation to match each level’s mood:
- Minigolf - breezy, relaxed major-key arpeggio, 100 BPM
- Lidl Lunch - upbeat, bouncy bass + kick/hat, 128 BPM
- Soccer - driving, pumping bassline, kick every beat, 142 BPM
- Tanzstunde - a genuine 3/4 waltz (oom-pah-pah), 132 BPM
- Above the Clouds - airy, sustained triangle-wave pads, no
percussion, 74 BPM
Loops natively (AudioBufferSourceNode.loop=true), crossfades
(~200ms) between tracks on level change, respects the same
gesture-gate the SFX system already needed (browsers block audio
until a click/keydown). A mute toggle (M key) exists - built to
audition new SFX against a quiet background, kept as an ordinary
player option.
[todo] Ambient / organic sounds — crowd cheer (Soccer level), footsteps, wind. Procedural synthesis is a worse fit than real recorded texture for these. Plan is Freesound.org (mixed CC0/CC-BY, check per file) or Kenney.nl audio packs (CC0, already retro-styled — may also fit some SFX better than my synth pass once tried).
[todo] Narration — per-level lines, narrated by Moritz (alias pixelsp33d) in German. Not a library at all: captions are already wired per the technical plan (PLAN.md), audio slots are empty, and dropping in his recordings later is a data change, not a code change.
Open question
Do the synthesized SFX and music actually feel right once you hear them in context? First pass is a reasonable default tuning (durations, frequencies, decay, tempo) but easy to retune — tools/make_sfx.py and tools/make_music.py are both plain Python scripts, rerunning either after tweaking any number is cheap.
Playtest Findings
Playtest findings log
Started 2026-08-08 (first live TV playtest session covering the Upperworld/
rickroll build). Distinct from NEXT.md (forward-looking plan) — this is a
log of things actually found by playing, human eyes on the running game,
not design proposals. Each entry gets a stable ID so later work (a fix, a
harness check) can reference it without re-describing it.
Fields: Status is one of open / dispatched (an agent/task is on it)
/ fixed (merged) / wontfix (decided not to address, with why).
Harness tracks whether this finding’s class of bug has been turned into
a durable automated check yet (see the planned mechanics-audit harness) —
— means not checkable by a test (visual/feel issues), todo means
checkable but not yet written, done means a real test exists.
Open
PF-7 — soccerShoes/danceShoes are single-chance, latent risk if ever hardened
Found: 2026-08-08, mechanics audit (MECHANICS-AUDIT-2026-08.md).
What: Unlike Tailored Clothes, soccerShoes/danceShoes exist only in
their _s2.ts files with no Season 1 twin — no season-pair redundancy.
Today both are soft gates (missed score/bonus only, never added to
moveSolids), so not currently broken. But if either is ever escalated to
a hard block the way the dress-code guard was, it would be a worse,
completely unmitigated softlock — no accidental jump-bypass exists for
these, because nothing currently needs one.
Severity: Low today, flagged as a landmine for future changes.
Status: open, informational — no fix needed unless/until either gate
becomes a hard block.
Harness: todo — same “hard-gate reachability” check the mechanics-audit
harness should encode generally would also re-check this if either item’s
gating logic ever changes.
Confirmed good (not a bug, don’t touch)
PF-23 — Confirmed good: the Clouds wind gust requires exactly one speed-tier boost to cross
Found: 2026-08-08, live TV playtest, final pre-demo pass on Clouds S2.
Torsten’s own words: “The wind gust needs exactly one flash (gained by a
cola). without it its impossible to reach the end of the game… I just
needed to fly back and collect a cola. Noice.”
What: Not a bug — confirmed by design. clouds_s2.ts’s own windZones
comment already spells out the exact math: the gust’s pushX=-80 is pinned
to exactly cancel RUN_BASE=80 (player.ts) at speedTier=0, so holding
run alone only hovers in place — real forward progress needs at least one
River Cola’s speed-tier bonus first. Reaching the level’s end without ever
collecting a single cola is intentionally impossible, not an oversight;
recorded here so it doesn’t get “balanced” away later by someone unaware
the wind’s exact strength was tuned specifically to require this.
Severity: N/A — positive finding.
Status: confirmed-good, no action.
Harness: — (the exact push/speed math is already documented inline in
clouds_s2.ts’s own comment; a dedicated automated “0 speedTier can’t
cross, 1+ can” check could be added later if desired, not required now).
PF-11 — Confirmed good: Soccer Shoes placement + a nearby speed pickup after respawn
Found: 2026-08-08, live TV playtest, Soccer S2.
What: Not a bug — a positive confirmation worth recording so it
doesn’t get accidentally “fixed” later by someone unaware it was
deliberate and liked. Soccer Shoes sits on a platform, and there’s a cola
(speed-tier) pickup positioned close enough to help recover pace right
after a fatal respawn resets speedTier to 0. Player’s own words: “I like
it.”
Severity: N/A — positive finding.
Status: confirmed-good, no action.
Harness: —.
Fixed
PF-22 — Ejected player’s spawn point overlapped a waltzing dancer’s sway
Found: 2026-08-08, live TV playtest, immediately after PF-20’s portal
re-entry fix made this observable at all (“player lands just beneath the
door and the next dancer who happens to be placed there is gently pushing
him into the door again” — noted first as charming, kept for one test
(test_ejected_player_gets_waltzed_back_into_the_portal), then reconsidered
as a real spawn-point bug: a permanent spawn shouldn’t depend on a moving
hazard’s phase). Root cause: exitUnderworld()’s default eject spot
(trigger.x - player.width - 4 = 631) sat 1px inside the x=600
dancePartner waltz item’s sway peak (~632) — a genuine takeHit()
knockback (not a solid-body push; dancePartner is never in moveSolids)
that landed the player right back inside the (now re-enterable, PF-20)
portal trigger on a recurring ~2.73s cycle, for as long as the player
stood still. Fixed 2026-08-08: PortalDef gained exitSide?: 'left' | 'right' (default 'left', unchanged for every other portal); Tanzstunde’s
portal sets exitSide: 'right', landing the player at x=665 — clear of
every nearby dancer, with 3px to spare before the x=680 GAPS entry
(falling into it hits FALL_OUT_Y, player.ts, and respawns at
spawnX=20). That safe strip is only ~17px wide total, tight but real.
Harness: done — smoke_test_season2.py gained
test_ejected_player_stays_put_not_pushed_into_gap_or_portal (replacing
the earlier “keep it” test), asserting an idle ejected player neither gets
swept back into underworld_tanzstunde nor falls into the gap across
several would-be waltz cycles; test_tanzstunde_portal_reenterable_after_eject
updated to walk left (not right) to re-enter, matching the new eject side.
PF-19 — Dance session timeout had no visible countdown or timeout FX
Found: 2026-08-08, manual playtest check (“dance session timeout in
Underworld ejects to Tanzstunde… Seems to work. Not clear if the timer from
Tanzstunde is counting or if the lesson gets its own counter. Also, there is
no visual indication of a timer running out.”). Fixed 2026-08-08:
PF-18’s DANCE_SESSION_TIMEOUT_S/sessionElapsed is its own dedicated clock
(never shared with the outer level’s levelTime, which is snapshotted/
restored around the Underworld visit by enterUnderworld()/exitUnderworld()
unchanged), but it had zero on-screen readout and abandonDanceSequence()
ejected the instant it fired, with no message or sound. Added: a live “Xs”
countdown (centered under the beat bar in the dance overlay, red under 10s,
mirroring hud.ts’s own level-timer convention), and a DANCE_TIMEOUT_HOLD_S
(0.8s) hold on timeout that flashes “TIME’S UP!” (reusing the existing
danceFeedbackText mechanism) and plays the time_out SFX before the eject
actually happens. Purely additive — the eject itself, and the fact that a
timeout grants no reward, are unchanged. See PF-20 for a separate,
pre-existing portal-re-entry bug this session’s live TV validation surfaced.
Harness: done — __ps33dDebug gained danceFeedbackText/
danceTimeoutHold; smoke_test_season2.py’s
test_dance_session_timeout_ejects_to_previous_room now also asserts the
“TIME’S UP!” hold fires (while still state:'dancing') before the eject to
'playing', with a real ~0.8s gap between the two.
PF-20 — Tanzstunde/Clouds portals were permanently one-shot per playthrough
Found: 2026-08-08, live TV playtest while validating PF-19 (“Portal can
not be reentered”). What: PortalDef.used (level.ts) is a single
shared one-shot gate, set true the instant a portal is first entered and
never reset except by resetAllPortals() on a full “quit to title”
(PF-17). That lock is load-bearing for Soccer/Lidl Lunch’s Underworld
chest portals (a real one-off item, farmable without it), but Tanzstunde’s
and Clouds’ dance portals got the same lock even though PF-18/PF-19’s
design intent was always a repeatable portal — their reward is already
capped a different way (danceSequenceStep’s per-step cost,
recallSucceeded’s own idempotency guard, DANCE_SESSION_TIMEOUT_S).
exitUnderworld() never touched .used on the way out, so once ejected
(via the exit ladder or PF-19’s timeout), the physical portal was dead
for the rest of the playthrough - confirmed pre-existing (unrelated to
PF-19’s changes; exitUnderworld()‘s body is untouched by that fix) but
surfaced only now. Fixed 2026-08-08: PortalDef gained oneShot?: boolean (default true, preserving Soccer/Lidl Lunch’s lock); Tanzstunde’s
and Clouds’ portal defs set oneShot: false; exitUnderworld() resets
used = false on exit whenever the portal it’s restoring isn’t one-shot.
Harness: done — smoke_test_season2.py gained
test_tanzstunde_portal_reenterable_after_eject, which walks in, times out
via ?danceTimeoutS=, walks back into the same trigger, and asserts it
re-enters underworld_tanzstunde a second time in the same playthrough.
PF-21 — DANCE_SESSION_TIMEOUT_S (60s) was too tight to actually learn the combo
Found: 2026-08-08, live TV playtest immediately after PF-19/PF-20
(“I managed to learn the sequence in 10 seconds. Had to start immediately
after the intro. So 10 seconds is [too] tough.” — the 60s real default left
too little margin for a cold-start attempt with no prior familiarity,
especially once the demo phase’s own runtime eats into the same budget).
Fixed 2026-08-08: raised 60 → 120, then — once PF-20’s portal re-entry
fix prompted the realization that this timer’s original anti-farm job may
not even be needed anymore if re-entry alone isn’t the exploit — set to a
deliberately generous 5-minute stopgap (DANCE_SESSION_TIMEOUT_S = 300) so
it’s effectively out of the way while the actual crediting design gets
rethought for Season 3 (see MECHANICS-SEASON3-PLAN.md). Not a final
value.
Harness: n/a (a tuning constant, not new behavior) - the existing
?danceTimeoutS= override keeps the automated timeout test fast regardless
of the real default’s value.
PF-18 — Tanzstunde teach + Upperworld recall were both farmable for free
Found: 2026-08-08, live TV playtest (“Both Tanzstunde training and recall
can be used to farm credits 100 on each move”). Fixed and merged 2026-08-08
(commit 5b48320): danceSequenceStep() (player.ts) used to ADD energy and
count toward itemsCollected (computeScore’s flat +100/item weight) - since
teach loops on any miss until a clean pass (PF-3) and recall’s portal is
re-enterable, both were strictly profitable to repeat with zero downside. Now
costs 5 energy and grants a flat 10 danceCredits (tracked separately from
itemsCollected), and a new DANCE_SESSION_TIMEOUT_S (60s real, overridable
via ?danceTimeoutS= for tests) caps how long any one visit can run - on
timeout, abandonDanceSequence() ejects the player back to the room before
the Underworld/Upperworld via exitUnderworld() (same as a normal portal
exit, not a respawn inside the same sub-level), doubling as an escape hatch
for a stuck/AFK player rather than a pure penalty.
Harness: done — 3 vitest cases (player.test.ts) pin the energy-cost/
credit math; smoke_test_season2.py extends the existing correct-inputs
dance test to assert the exact economy (100 energy → 80, 0 → 40 credits after
a clean 4-step pass) and adds a dedicated timeout/escape-hatch test.
PF-17 — Portals never reopened for a second playthrough on the same page load
Found: 2026-08-08, live TV playtest (“Portals should reset after each
game. Now no portals open for the second game regardless of chosen player”).
Fixed and merged 2026-08-08 (commit 5b48320): PortalDef.used lives on
the LEVELS/LEVELS2 module-level LevelData singletons - loadLevelData()
never clones level itself, only items - so marking a portal used (the
existing “can’t be farmed” gate within one playthrough) silently persisted
for the rest of the page session, including into a second playthrough via
“End Current Game” + a new player choice. This is a different bug from the
Season 1/Season 2 PortalDef aliasing fix (see “Season 1 vs. Season 2” in
MECHANICS.md) - that one stops the two seasons from sharing one object;
this one is about the same season’s own object never resetting across a new
game. Fixed: quitToTitle() now calls a new resetAllPortals(), clearing
used on every portal across both LEVELS and LEVELS2.
Harness: done — smoke_test_season2.py’s anyPortalUsed debug flag
verifies a portal marked used gets cleared by the real OSD “End Current
Game” button, without needing to script a full physical replay back to it.
PF-16 — kn00t ambushes were one-shot forever instead of a recurring threat
Found: 2026-08-08, live TV playtest request (“should be recurring… after
distance/time… only from season 2 onwards”). Fixed and merged 2026-08-08
(task #16, commit ffbfcff): distance+time re-arm (AMBUSH_REARM_DISTANCE_PX=200,
AMBUSH_REARM_TIME_S=5, both evaluated live, latching only once both clear
simultaneously) replacing the permanent collected=true. Real geometry backing
the thresholds: Lidl Lunch’s ambush is 449px from spawn (fastest sprint back
~3.2s, comfortably under the time gate), Soccer’s is 1524px. Verified
empirically: near-spawn respawn still doesn’t chain-death (the original 2026-08-07
softlock stays fixed), and a real hit-respawn-wait-return cycle does re-trigger.
Harness: todo — same shape as PF-4/PF-7’s reachability checks.
PF-1/PF-2/PF-8 — New portals were unmarked and crammed right before their boss
Found: 2026-08-08, live TV playtest, Tanzstunde S2 + Clouds S2. Fixed and
merged 2026-08-08 (tasks #17/#18, commits d6ea0b5/d17be01):
- Tanzstunde teach portal: new
backstage_door.pngsprite; repositionedx=1795→645, next to the seconddancePartnerwaltz; made a genuine optional detour (requires a deliberate jump onto an elevated platform, matching Soccer’s own portal precedent) rather than an unmarked mandatory walk-through. - Clouds Upperworld portal: new
drawSkyPortal()visual (pulsing glow + orbiting sparkles, no existing sprite fit a mid-air portal); repositioned to the world ceiling near spawn (x=30,y=0), reachable only via sustained flight, fully clear of the boss and all plane patrols. Deliberately kept separate from the wind zone (PF-6) rather than integrating them. Harness: — (visual/discoverability, not a checkable invariant) for the markers; the reachability-shape question (PF-8) folds into the same harness item as PF-4/PF-7.
PF-6 — Clouds’ wind-gust gauntlet was imperceptible, mechanically and visually
Found: 2026-08-08, live TV playtest, Clouds S2. Fixed and merged 2026-08-08
(task #18, commit d17be01): push -35→-80, pinned exactly to RUN_BASE=80
(walking fully overpowered, a bare run exactly cancels, only real speed-tier
build-up buys forward progress). Zone widened/moved (x=1550,w=250, was
x=1700,w=120), leaving a real buffer before the boss. Telegraph now renders
140px before entry (was zero warning) and switched from white (measured to
blend into Clouds’ actual sky background almost exactly) to amber for real hue
contrast.
Harness: done — the wind-magnitude/telegraph assertions in
smoke_test_season2.py now check the real numbers, not just “ran without error.”
PF-9 — Minigolf pit-4/crawlspace geometry overlap
Found: 2026-08-08, mechanics audit (MECHANICS-AUDIT-2026-08.md). Fixed
and merged 2026-08-08 (task #20, commit b1c0f70): TUNNEL_FLOOR_W 140→122,
stopping the crawlspace floor exactly at the bridging crate’s edge instead of
18px into pit 4’s own heal fill-rect. Verified via a real A/B: reproduced the
exact bug on the unfixed code first (a scripted idle through the heal window
produced a genuine 21px y-snap), then confirmed it’s gone after the fix.
Harness: —.
PF-5 — Only one kn00t cameo (Lidl Lunch freezer) actually landed as a joke
Found: 2026-08-08, live TV playtest feedback, confirmed via a full human
playthrough (“the cameos hurt as expected”). Fixed and merged 2026-08-08
(task #19, commit 6108088), per CAMEO-COMEDY-2026-08.md’s root-cause finding
(both weak cameos secretly wore a different level’s own future boss portrait):
Minigolf swapped to the never-before-rendered kn00t_1 “camo” and hidden in a
sand-trap pit (hiddenUntilHazard:5); Soccer swapped to kn00t_3 “pirate”,
position unchanged. Added a dedicated ambush_reveal SFX (dissonant stab +
descending cackle) layered on top of the existing hurt, not replacing it.
Also folded in (same commit): chest resize 10×8→20×16, since it was
previously the smallest item in the game despite being the rarest/highest-value
pickup.
Harness: — (comedy quality isn’t testable; the sprite/placement change
itself is covered by existing ambush smoke-test assertions).
PF-3 — Dance lesson (teach mode) didn’t loop until the player succeeded
Found: 2026-08-08, live TV playtest, Tanzstunde S2. Fixed and merged
2026-08-08 (task #14, commit ce74f99): real demo phase (the
dancePartner NPC performs DANCE_COMBO once, banner WATCH!), loop the
interactive attempt until 1 clean pass, persistent per-move learned
checkmarks, and a LEARNED!/RECALLED! fanfare reusing drawGoalFanfare()’s
visual language. Re-confirmed post-merge via full regression: an all-miss
run loops indefinitely (teachCompleted stays false across 2+ attempt
cycles) instead of resolving after one pass; a correctly-timed run
resolves after exactly 1 clean pass.
Harness: — (UX guarantee, not a static invariant).
PF-10 — Dance overlay’s “->” separator overloaded with real left/right inputs
Found: 2026-08-08, live TV playtest, Clouds S2 recall screen — confirmed
as the proximate cause of a real failed attempt (misread as “press right
now” instead of “and then”). Fixed and merged 2026-08-08 (folded into
task #14, commit ce74f99): removed the arrow separator entirely — teach
mode shows [X]/[ ] checkmarks per move, recall’s hidden-label fallback
uses a numbered list.
Harness: —.
PF-4 — Dress-code guard’s only bypass was accidental and load-bearing
Found: 2026-08-08, live TV playtest + investigation, Tanzstunde S2 — a
4px-margin accident was the only thing standing between the guard and a
genuine softlock (later softened by the mechanics audit finding two
independent chances at Tailored Clothes, not one — see the commit for full
context). Fixed and merged 2026-08-08 (task #13, commit 738cfd9):
guard height 54→90 (top y=138→102); from flat ground a standing jump is
now 40px short (still uncheesable); from the Dance Shoes platform,
WALK_SPEED fails (18px short) but RUN_BASE clears with 15-26px of real
margin — verified via actual scripted jumps from three takeoff points, not
just arithmetic. New takeoff_marker.png telegraph (reusing the existing
windsock.png pole+flag convention). Sprite redesigned from
ladder-looking to a stern bouncer on the original stanchion base (two
alternates generated, not wired in). Re-confirmed post-merge: the running
jump clears (reached x=874.2), the same jump walking fails safely
(grounded at x=808.0, retriable, not a softlock).
Harness: todo — “is every hard gate’s required item reachable given
one-way progression” is exactly the check the mechanics-audit harness
should encode generally, this instance just got a manual fix first.
PF-12 — Only one poisonEgg existed, so the Knight capability barely registered
Found: 2026-08-08, live TV playtest request: “add more poison eggs so
the uervel knight gets more obvious.” Exactly one poisonEgg existed in
the whole game (Season 1 Lidl Lunch only, x=1520) — the
hasKnight-defeats-poisonEgg capability only ever got one chance to
land as a felt before/after moment, and never in Season 2 at all despite
the Knight persisting across seasons. Fixed and merged 2026-08-08
(task #15, commit c66db76): 3 new poisonEgg patrols added to
lidl_lunch_s2.ts — one before KNIGHT_X=482 (the real-danger beat), two
after (the “I can walk through this now” payoff, repeated). Net effect in
one S2 playthrough: 1 pre-Knight egg, 3 post-Knight eggs (confirmed the
original Season 1 egg already carries into S2 automatically, no
season-filter removes it).
Harness: —.
Wontfix
(none yet)
Note on the final combined-merge verification (2026-08-08)
After merging all six fixes above into main in sequence, one run of the full
smoke_test_season2.py suite (15 checks) showed a single transient failure —
test_underworld_tanzstunde_correct_inputs_no_errors reported state as
'title' instead of 'playing'. Investigated before trusting it: re-ran the
same test in isolation (passed cleanly) and re-ran the full suite again
end-to-end (passed cleanly, 15/15). Confirmed as timing contention from many
sequential Playwright pages sharing one browser instance under load, not a
real regression from the merge — this test’s page.wait_for_timeout() calls
have tight-ish margins (documented in its own docstring as “approximate
timing… to avoid being flaky on precise ms timing,” which held for the
single-test case but not always under full-suite load). Low-priority follow-up
if it recurs: loosen this specific test’s timing margins slightly.
Season 3 Plan
Ideas for season 3, mainly from real playtests. See below for bug reports too.
Running skill as achievement
- Powering through all levels in running state is effective but should be more deliberate by introducing a small cost in energy. In the easier levels this should not hurt at all. The tailored-clothes guard is a real test for it - running is required to jump over it when the tailored clothes have not been collected (dress-code bypass jump).
Season 3 starts without the running skill for the player. This will be earned later in the game, e.g. in the first underworld chest. I tried it - the waterfall in the underworld can be crossed without running, but the player must jump through and accept severe injuries. So it’s possible; the running skill should be earned in the chest just after the waterfall.
Can be indicated by the soccer shoes we already know. This time they are added to the inventory by collecting from a chest and not picked up from a platform.
I made some tests to see if level 1 (Minigolf) can be completed without the running skill. It’s hard, especially the bigger sandpits. So in season 3 there should be plenty of spare cola on elevated platforms so the player can finish it even after a respawn. This will probably need to be tuned right.
Running skill in Above the Clouds
- Similar to above for wind-gust zones where running is required. Player is required to use power economically across the level. A protection against the wind gust is a powerup idea for the second chest before the player enters the clouds scene.
Rewarding soccer goals
It’s possible to kick a ball into the goal - the one lingering in front of the goal is the easy one that always works. The trick is to push against the ball (just release the running button on the gamepad) and push it briefly at the right time (in my tests, just after or at a direction change of the ball). Result: the ball lands in the goal. So here is my idea for season 3: remove the static easy winning ball and increase the reward for a goal. I think it’s already possible to kick more than one ball into the goal. Add a reward multiplier of 2 (or 3?) to make it worthwhile. Of course - all still gated on soccer shoes in the inventory.
Fixed in August version already
Manual check (PF): dance session timeout in Underworld ejects to Tanzstunde, the level before the lesson. Seems to work. Not clear if the timer from Tanzstunde is counting, or if the lesson gets its own counter. Also, there is no visual indication of a timer running out. The timeout should be indicated by at least some FX.
Tanzstunde dance credits/score economy - needs a real redesign
Found 2026-08-08, same live TV session as the timeout-FX check above. The portal into the Underworld lesson used to be permanently one-shot per playthrough (a bug, not by design - fixed same session: PortalDef.used was a blanket lock meant for Soccer/Lidl Lunch’s farmable chest portals, and Tanzstunde/Clouds got swept into it by accident). Now that re-entry actually works, watching the score while repeatedly entering/dancing/ getting ejected/re-entering exposed a real design problem, not just a display glitch:
computeScore()(hud.ts) isitemsCollected*100 + speedTier*250 + round(energy)*5 + danceCredits.danceCreditsitself only ever goes up (+10 per perfect/good step,danceSequenceStep()in player.ts) - that part’s fine. Butenergyis baked into the same displayed number at a 5x weight, anddanceSequenceStep()also costs -5 energy per step. Net effect: landing a good dance step - the moment you’re supposed to feel rewarded - visibly drops the on-screen score by 15, because the energy cost (-25 displayed) outweighs the credit gain (+10 displayed).- Separately, walking into (or back into) the portal runs
loadLevelData()->player.respawn(), which snaps energy to full in one frame - an unrelated +ve jump in the same displayed number that has nothing to do with anything just earned. So across a few dance/eject/re-enter loops the visible score saws down-while-dancing, flat-while-ejected, up-on-re-entry. That’s what looked like nonsense live - it is nonsense, or at least two unrelated ledgers (an energy economy and a credits economy) got folded into one number that can’t represent both sensibly at once. - With re-entry now working,
DANCE_SESSION_TIMEOUT_S(bumped 60->120-> 300s as a same-session stopgap, see PLAYTEST-FINDINGS.md PF-19/PF-21) is worth reconsidering from scratch rather than re-tuning again: its original job was anti-farming for free, repeatable, no-downside visits, but if the portal can always be walked back into anyway, a time limit on a single visit doesn’t obviously add protection against anything a real cap on total earnable credits (or on how good the reward per visit can be) wouldn’t already cover more directly. Open question for season 3, not decided: does the timer keep any real job at all beyond a generous AFK-tab safeguard, or does fixing the crediting model (see below) make it redundant?
What to actually figure out before touching any of this again:
- Should
danceCreditsever have shown up inside the same number as energy in the first place, or does it want its own separate, honest HUD readout (the way the countdown timer got its own readout this session)? - If energy cost per step stays, should the displayed score stop
including live
energyat all (only bake in credits/items/speedTier), so spending energy on a dance step doesn’t read as a net loss? - Should
player.respawn()’s energy-to-full reset be scoped to actual respawns (falling, fatal hit) rather than firing on every portal entry vialoadLevelData()- i.e. is “walking through a portal” supposed to refill energy at all, or is that itself the accidental part? - Once the above is sorted, is
DANCE_SESSION_TIMEOUT_Sstill pulling weight, or can it be dropped/pushed to something like 10+ minutes purely as an AFK safeguard with zero gameplay-balance role?
Update, same day: the ejected player standing at the door getting gently waltzed back into the portal by the dancePartner’s sway (originally kept on purpose as a charming find) was reconsidered and fixed properly instead
- a permanent spawn point shouldn’t depend on a moving hazard’s phase.
PortalDef.exitSide:'right'(tanzstunde_s2.ts) now lands the ejected player clear of the dancer, with a 3px margin before the level’s own x=680GAPSentry. Locked in bytest_ejected_player_stays_put_not_pushed_into_gap_or_portalin smoke_test_season2.py (supersedes the old “keep it” test of the same scenario). See the next section, though - the underlying idea (an eject spot that isn’t automatically safe) turned out to be worth keeping deliberately, just not by accident and not here.
Deliberate “risky respawn” challenge (season 3 draft, not implemented)
Prompted directly by the accidental Tanzstunde version above: instead of every portal/Underworld exit always landing the player somewhere safe (the norm, and still correct for Tanzstunde’s real fix), season 3 could build a portal on purpose where the exit spot sits exactly on top of a gap or obstacle - the player free-falls (or gets pushed into the hazard) unless they steer clear immediately. The fairness condition: pair this only with an elevated exit (like Tanzstunde’s own TEACH_PLATFORM_Y, above real ground level) so there’s genuine airtime between landing and the fall actually resolving - a real reaction-time skill check, not an unavoidable cheap hit dressed up as a spawn point.
Notes for whenever this gets designed for real:
- Needs an explicit telegraph so it reads as “deliberate challenge,” not “buggy spawn” - same “mark it before it’s reachable” instinct this file already uses elsewhere (takeoffMarker in tanzstunde_s2.ts, the windsock in Above the Clouds).
- The airtime/margin needs real playtesting to tune, same discipline as the DANCE_SHOES_STEP_Y / dress-code-bypass-jump numbers already in tanzstunde_s2.ts - get it wrong and it’s either trivial or an unavoidable cheap hit, nothing in between reads as “fair.”
- Could generalize the same
PortalDef.exitSidefield just built for this fix rather than inventing a parallel system - e.g. a third option alongside ‘left’/‘right’ that deliberately targets a hazard, or an explicit offset. - Should live somewhere new/purpose-built, not retrofitted onto Tanzstunde’s real exit, which just got fixed specifically to not do this.
No implementation this session - captured as a mechanics draft only, per Torsten’s own framing. This is a “figure out the design, then build it” item for season 3, same as the other sections above.
Prepper: prep items in the bag / player picks from inventory
Incident: We found a rare case where there was no energy left to finish the game - the portal to recall the dance lesson was blocked by a wind gust. We had just lost our energy (flashes) by accidentally touching evil kn00t in the clouds level. Then the thought: what if we had prepped some cola when the speed tier was full anyway? We could just open one now from our savings.
So: when the player collects a backpack item (from a trove? in season two?), we can drop it in and recall it later. Lots of stuff to build for this. Maybe the backpack opens only in the clouds level so items can only be spent there (when actually needed). Can be linked to the jetpack somehow. Just a coincidence so far. Let’s see.
Side question: what are unused items that can be collected so far but have no meaning in the game (like the dance shoes)?
Speedrun adventure
One level where the goal is not to collect items but to complete it as fast as possible. Remaining time is credits. Can be extended into speedrun mechanics.
Speedrun mechanics
A second winning tier where the goal is to reach the end as quickly as possible. One idea is to add up the remaining time per level. Other crediting possible too.
Moving platforms
A level that forces the player to jump across moving platforms or fall into lava.
Bug found that needs fixing
Reverb build-up. The audio engine in underworld caves seems not to clean up properly. Each time we re-enter, the reverb builds up one layer. After two or so, the sound becomes really unwieldy. Tried to restart the server - no chance. Only restarting the browser (TV Bro on my TV) helped. Maybe check if the reverb engine is properly cleaned on cave exit.
ATProto Integration Ideas
Just a few thoughts on atproto integration.
Instead of logging in with your atproto handle, we implement an “add your player” flow that only needs a sign-in step once, run in the browser. After that the new player takes one of the slots in the game.
As lexicons I see two things: de.pixelsp33d.player and de.pixelsp33d.level, each with its own designer tool in the browser, writing its results as lexicon entries to the player’s own PDS.
Huge interop opportunities with trezy.codes games lexicons. Players can borrow ideas from rpg.actor similarly to how we borrowed the HKDF method for generative art - basically all kinds of traits can be built in a similar way. pixelsp33d game can integrate with rpg.actor directly as the player avatar; otherwise we need to write a game designer (level, player avatar) that writes records on the user’s behalf on their PDS. Loaded in the game with the “add player” flow as described above.
Two possible contribution flows: 1) select from a palette derived from pixelsp33d lore - music and FX can be written by our tooling, or (in the case of bytebeat) re-use existing library content (assuming all is in the public domain, royalty-free, BSD). 2) Coders can contribute via Claude Skills and code-forks and build against a spec provided by us.
Bottom line: this connects with haiku.garden’s guide, which was discussed on the community discourse as a covenant, etc. Two tasks: minting credentials is one of them.
Some existing interop candidates from my own fleet:
pixelsp33d as game host and lexicon authority (NSID) uervel.net as player example kn00t and Mama currently have no protocol provenance
haiku.garden as an episode or level design space memo.dog as a guard similar to the tailored-clothes guard
atmospheric.computer as the project handle
research tasks
Check out trezy.codes’ games.games ideas. Revisit the gen art in haiku.garden and its two origin sites.
MVP is the “add player” flow and a minimal lexicon. Then post in the community forum.
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).
Tunables Design (2026-08)
Tunables & data-driven design (2026-08-08)
Companion to ARCHITECTURE-REVIEW-2026-08.md §1 — split out into its own doc because
it’s a reusable discipline for this codebase, not a one-off note buried in a bigger
refactor writeup. Covers: what actually varies by season, why that’s two different
problems, and how each gets modeled — including a same-day reversal on one of the two.
Two different problems, not one “tunables system”
Content that varies by season — extra hazards/items/platforms/wind zones. Fixed by
giving each level its own explicit per-season LevelData object (spread the previous
season’s, override arrays), replacing the season2Extra* splice-at-load-time pattern.
This stays a plain data shape — a level’s hazard list isn’t an entity-component
relationship, it’s just “what’s in this level’s data.” No reversal here; per-level
LevelData per season is still the right fix, unrelated to the recs question below.
Bare numeric constants that vary by season — e.g. HAZARD_REVEAL_DISTANCE_S1 = 130
/ HAZARD_REVEAL_DISTANCE_S2 = 85, picked by season === 2 ? ... : ... in main.ts.
A two-constant ternary is a 2-way switch by construction — it doesn’t extend to a third
season, it has to be rewritten. This is the one that needed real design discussion.
What’s already established practice, elsewhere
Checked before proposing anything: MUD’s own answer to “structured, tunable config
data” is a table with a schema and a key (mud.dev/world/tables,
e.g. keyed by player) — the same idiom this codebase already half-uses for
SEASON2_LAYERS[id] (keyed by level id). The general game-design term for the same
move — pulling tunable/balance values into a data structure instead of scattering
constants through control flow — is data-driven design. Both point the same
direction: don’t hardcode a value per branch, look it up from a table keyed by
whatever varies.
How the decision actually got made (kept as the record, not just the answer)
Three passes, same day, worth keeping because the reasoning survived better than either extreme:
Pass 1 — plain table, no recs. Reasoning: “a season isn’t an entity being queried,
it’s a single global config lookup” — using the same test that correctly justified recs
for Player equipment (a real entity, proven by a regression test showing two Player
instances have independent equipment), but applied to today’s 2-season reality rather
than to what’s actually being planned.
Pass 2 — overridden toward recs. Seasons are becoming a real, growing population —
Season 3 is planned work, not speculative — and a Record<number, T> assumes every
season defines every column; a genuinely-new-to-Season-3 tunable would mean optional
fields sprinkled everywhere, the same shape problem season2Extra* already has today,
one level up. Sparse, heterogeneous per-entity data queried generically is what recs
components are for, plus a real synergy with the generic debug-panel idea
(ARCHITECTURE-REVIEW-2026-08.md §2 addendum).
Pass 3 — corrected by precisely defining ECS, then classifying every real site against it (not against pre-ECS code habits). ECS earns its place only when (a) there’s a real population of independently-existing things carrying heterogeneous data, and (b) something needs to query across a subset of them generically — not just “look up this one specific thing I already know the identity of.” Having multiple keys in a lookup table doesn’t satisfy (b) on its own — every enum-keyed config object would qualify if it did. Going through §1’s full 16-site inventory against this bar:
| Site | Classification |
|---|---|
season2Extra* hazards/items/platforms/wind zones | Level content — dissolves into per-season LevelData fields, no component |
SEASON2_LAYERS[id] (music) | Also dissolves — becomes a musicLayer field directly on each season’s LevelData |
DANCE_LEVEL_ID/UPPERWORLD_LEVEL_ID (the Upperworld build) | Level-existence — a mechanic exists because a season-specific LevelData references it via a portal. Already correctly shaped (clouds_s2.ts/tanzstunde_s2.ts) |
hasSoccerShoes equipment gates | Player’s concern, already solved, unrelated to Season |
HAZARD_REVEAL_DISTANCE_S1/_S2 | The one genuinely global, cross-level numeric constant with no per-level home |
markGameCompleted() season gate | Marginal one-off meta boolean, not worth componentizing |
Almost everything dissolves into §1’s already-agreed per-season-LevelData refactor
with zero new machinery — including everything the Upperworld build touched, which
independently arrived at the correct level-content-scoped shape without needing this
doc at all. The one real, clean candidate — precisely because it’s global and cross-level,
not because “seasons are a population” in the abstract — is HazardRevealDistance.
The design, as built
Season is a real recs entity; the one genuinely-global tunable is a component on it,
same createWorld/defineComponent/setComponent/getComponentValue API the
equipment refactor already uses on Player — not a hand-rolled port, the actual
installed library. Implemented in src/game/season.ts:
export const seasonsWorld = createWorld();
const SEASON_1 = createEntity(seasonsWorld, undefined, { id: 'season1' });
const SEASON_2 = createEntity(seasonsWorld, undefined, { id: 'season2' });
const HazardRevealDistance = defineComponent(seasonsWorld, { value: Type.Number }, { id: 'HazardRevealDistance' });
setComponent(HazardRevealDistance, SEASON_1, { value: 130 });
setComponent(HazardRevealDistance, SEASON_2, { value: 85 });
main.ts’s one read site is now hazardRevealDistanceFor(season), replacing the old
HAZARD_REVEAL_DISTANCE_S1/_S2 constant pair and its ternary. Season 3 adding a
genuinely new global tunable is a new defineComponent call in season.ts, not a new
constant pair plus a rewritten ternary at the call site.
Scope note
Music-layer selection and level-existence checks (DANCE_LEVEL_ID etc.) do not
become Season components, despite varying by season — per the classification above,
they’re level content and dissolve into §1’s per-season LevelData refactor instead.
Don’t force everything through season.ts just because the module exists; re-check
which category any new season-variant value actually falls into first.
Status
Implemented, verified, and merged to main: src/game/season.ts +
src/game/season.test.ts (3 unit tests). tsc --noEmit clean, vitest run 10/10,
both Playwright smoke suites re-verified end-to-end on trullala via
tools/fleet_test.sh (6/6 PASS) — Minigolf’s hazard-reveal behavior (the one thing
this touches) unchanged. The content half of §1 (the per-level LevelData-per-season
restructure itself) is also done — see ARCHITECTURE-REVIEW-2026-08.md §1 — so §1 is
now fully closed, both halves.
Finale Design (2026-08)
Grand finale + rickroll trove — design proposal (2026-08-08)
Decided 2026-08-08: the recommended combination below (1b + 2c + 3a) is confirmed
and merged to main. The trove’s room is named “The Upperworld” (see NEXT.md’s
two finale sections for the final wording). The axis breakdown below is kept as the
rationale record, not because the options are still open.
Important scope note, added 2026-08-08 after a follow-up question: “merged” covers
the mechanic only — recall a sequence correctly, a real chest spawns, a RECALLED!
fanfare plays, and triggerRickrollStub() flips one boolean flag. That’s it. The
actual rickroll — an 8-bit animation, a music sting, anything a player would see or
hear — does not exist. triggerRickrollStub() is a literal no-op beyond the flag,
exposed only for the test suite to verify the mechanic fires correctly. The real
payoff stays blocked on sourcing royalty-free-cleared audio (see the licensing note
in NEXT.md’s “rickroll gag” section) — that’s still 100% unbuilt.
Decided: one mechanic, not two objects
Torsten’s own description settles what was previously an open question (“is ROLL.txt’s trove the same object as the dance-sequence chest?”): yes. The mechanic is a single continuous thread — learn → recall → unlock → payoff:
- Tanzstunde’s dance QTE teaches the player a specific input sequence (Simon-Says
style — today’s fixed
DANCE_COMBO = ['crouch', 'left', 'crouch', 'right']). - Later, the player repeats that same sequence at a trove to open it.
- Opening it triggers the rickroll payoff.
So the dance chest and the rickroll trove are the same chest, opened by the same taught sequence. What’s still open — and what this doc actually proposes options on — is where that trove physically sits, how the recall is presented, and when in the game’s flow it pays off.
What’s already there, grounding every option below:
chest(items.ts) is an established reward-progression “tier-2 pickup” sprite, already reused across both existing Underworld sub-levels (River Cola’s, the mushroom house’s) — the likely “known good from an earlier level” prop.- Level order is fixed and one-way within a run:
[Minigolf, Lidl Lunch, Soccer, Tanzstunde, Clouds](main.ts’sLEVELS/LEVELS2), advanced byadvanceLevel()incrementing an index — no backtracking exists. The only level reachable after Tanzstunde in the same run is Clouds. Revisiting an earlier level at all means a full Season 2 replay (New Game+), not backtracking mid-run. - The dance overlay today (
drawDanceSequenceOverlay) already shows the upcoming move labels and a beat-timing bar live during the QTE — teaching is already guided, not blind. Nothing currently tests whether the player actually remembers the sequence without the overlay’s help. hasCompletedGamealready exists and is persisted (localStorage) — it currently gates title-screen attract mode, but the same flag could gate anything else.gameComplete(Season 2’s real ending) already runs a parametrized backdrop montage (drawBackdropSequenceoveractiveLevels) — a slot built to take more frames, not something needing new plumbing.- Music earmarked for this:
deep_drone_bells.wav(“a grand-finale reveal moment”),storm_atmo.wav(“a hidden-level entrance”),bytebeat_1/2/3.wav(the rickroll joke itself). Standing family-game rule: misses never punish, only reward skill.
Axis 1 — where does the trove physically sit?
- 1a. Inside/near the new Tanzstunde Underworld (the sub-level built to relocate the dance QTE into). Recall gap is short — seconds to minutes after learning it. Cheapest: one new level, one new portal, done.
- 1b. In Clouds (the only level reachable afterward in the same run), reusing the
chestsprite as a “known good” prop transplanted somewhere new — genuinely fits ROLL.txt’s implied distance, and the recall gap is real (a full level’s worth of intervening gameplay). Costs the same new Tanzstunde Underworld/portal as 1a plus placing a second, separate chest object in Clouds.
Placing it in an earlier level (Minigolf/Lidl Lunch/Soccer) isn’t viable without new backtracking infrastructure that doesn’t exist and isn’t worth building for this alone — not proposing it.
Axis 2 — how is the recall presented?
- 2a. Blind recall. At the trove, no on-screen move-label hints and no beat-timing
bar — pure memory test of the sequence taught in Tanzstunde. Matches “ultimate chance
to rickroll” as a genuine gotcha. Needs a second, simpler input-reading path alongside
updateDanceSequence()(same combo data, no overlay). - 2b. Same guided overlay, reused. The trove re-runs the existing dance QTE wholesale (same overlay, same hints), just gated behind reaching the trove instead of a fixed x. Cheapest — zero new UI — but weakens “recall” to “dance again.”
- 2c. Middle ground. Keep the beat-timing bar (rhythm help) but drop the move labels — tests memory of the sequence while still helping with timing, which is more reflex than memory anyway.
Axis 3 — when does it pay off relative to gameComplete?
- 3a. Same-run, mid-flow. Reachable as soon as the player gets to Clouds/the new
Underworld, before the run’s actual ending. Payoff plays immediately in-level; if
found, it also echoes as one extra frame in the later
gameCompletemontage. No new persisted state beyond a same-run “found it” flag. - 3b. Post-completion capstone. Gate the trove (inert/absent) behind the existing
hasCompletedGameflag, so it only becomes openable on a replay after finishing the game once. True “you have to have already seen the grand finale to unlock this” framing — but note the tension: the payoff then never appears during the actual finale run itself, only on a later replay, which could read backwards for something billed as the grand finale moment.
Recommended combination
Not a decision — a concrete starting point to react to: 1b + 2c + 3a. Build the
Tanzstunde Underworld/portal for the relocated dance QTE (teaching the sequence stays
exactly as guided as today), place the actual trove in Clouds reusing the chest prop
(real distance, real recall), test the sequence with rhythm help but no move labels,
and let it pay off in the same run with a later gameComplete echo — so the “grand
finale” framing is earned by players who catch it on a normal first playthrough, not
locked away from the very run it’s supposed to cap off. 3b (a deeper post-completion
variant) could layer on top later without conflicting with this, if a second, harder
version is ever wanted for completionists.
Cameo Comedy (2026-08)
kn00t cameo comedy audit — why the freezer works and the other 3 don’t (2026-08-07)
Torsten playtested Season 2 live on a TV. Verdict on the 4 kn00t sight-gag-turned-ambush
cameos (PLAN.md “kn00t cameo ambushes” section, items.ts lines 91-94): the Lidl
Lunch freezer one is funny; Above the Clouds, Minigolf, and Soccer are not. This doc
builds a checkable rubric from the one that works, scores the other 3 against it,
diagnoses exactly what’s missing from each, and proposes concrete redesigns. Nothing
here is decided — it’s a set of options for Torsten to react to, same spirit as
FINALE-DESIGN-2026-08.md.
Not in scope: no code changes, no asset generation. This doc only.
1. What’s actually on screen today
Read directly from the level files and items.ts, not paraphrased:
| Level | Portrait used | Placement | Sprite size | Reveal mechanism |
|---|---|---|---|---|
Lidl Lunch (lidl_lunch.ts:96-101) | Divingkn00t (swim goggles, snorkel) | Tucked behind a freezer_door decoration in the frozen aisle, x=469 | 12×12, behind a 20×15 door | Peeking out from behind an object |
Above the Clouds (clouds.ts:88-92) | Cyberkn00t (grey robot head, red eyes, antennas) | Floating alone in open sky, x=700, y=40 (near the ceiling) | 24×24 | None — fully exposed in open air |
Minigolf (minigolf.ts:121-128) | kn00tInBlack (sunglasses, black turtleneck) | Standing at the first tee, x=60 | 12×12 | None — standing in the open, described in-code as “watching from the sidelines” |
Soccer (soccer.ts:67-74) | kn00tOpenHair (long loose hair, no headwear) | In the goal net, x=1544 | 10×10 | Partial — obscured by net mesh |
I also opened the actual portrait art (assets/src/kn00t/gdrive/*.png, assets/src/kn00t/kn00t_1..3.webp, kn00t_67.gif) rather than going by name alone. What each one actually looks like, confirmed visually:
| Portrait | What it actually shows | Currently assigned to |
|---|---|---|
| Divingkn00t | Yellow swim goggles + snorkel, diving mask | Lidl Lunch cameo (works) |
| Cyberkn00t | Grey metal robot head, red LED eyes, two antennas | Clouds cameo + Clouds’ own boss (1st phase) |
| EvilOriginalkn00t | Straw hat, glowing red eyes, rainbow shirt visible at the shoulders | Clouds’ own boss (2nd/real phase) |
| Godkn00t | Cyan hair/beard, gold robe, celestial palette | Reserved for post-game only |
| kn00tInBlack | Sunglasses + black turtleneck/suit — a “Men in Black” look | Lidl Lunch’s own boss — also spent on Minigolf’s cameo |
| kn00tOpenHair | Long, loose flowing hair, no hat, no costume signifier | Tanzstunde’s own boss — also spent on Soccer’s cameo |
| kn00tRedShirt | Plain red shirt/shoulders, same hair as OpenHair | Soccer’s own boss |
| Lazykn00t | Bare shoulders, no shirt at all — the “didn’t bother getting dressed” look | Minigolf’s own boss |
| kn00t_1 (unclaimed) | Straw hat + grey/orange camouflage-pattern mask + green/yellow camo collar | Not used anywhere |
| kn00t_2 (unclaimed) | Top hat, formal black suit, red tie/band, a cane or wand at the shoulder, a yellow flourish by the face | Not used anywhere |
| kn00t_3 (unclaimed) | Eyepatch, wide-brimmed black hat with gold band, brown leather jacket — pirate/outlaw | Not used anywhere |
| kn00t_67.gif | Straw hat + rainbow shirt + blue eyes, two-frame idle wave | The unbuilt “6-7” easter egg |
The kn00t_67/EvilOriginal pair is worth flagging on its own: same straw hat, same rainbow shirt — the only difference is blue (harmless) vs red (menacing) eyes. The game already has this costume-recolor callback built into its asset set; it’s just never been put in front of a player yet (see §5).
2. The rubric, built from the one gag that works
Four checkable properties, derived directly from the freezer cameo, not asserted in the abstract:
R1 — Pun bridge. A legible logical chain from the portrait’s own visual theme to the level’s setting. Diving (mask, snorkel, cold water) → frozen food aisle. The bridge is “diving happens in cold water; a freezer is the coldest thing in a grocery store” — two hops, both concrete, neither requiring narration to explain.
R2 — Site specificity. Tied to one named, already-existing prop in the level (a
freezer door), not “somewhere in the level.” The freezer door is itself a real
decoration (freezer_door.png) that exists independent of the joke — the gag rides on
top of set dressing that was going to be there anyway.
R3 — Hide/peek reveal, not open display. He’s behind the door, visible only as a sliver — the composition itself reads as “sneaking a look at you,” which is inherently funnier than a character standing or floating in full view, and critically, it’s the same body language as something about to jump out. This is what makes the Season 2 fatal flip feel earned rather than arbitrary (see §5).
R3.5 — Borrowed-reveal check (found while comparing all four, not assumed going
in): does the cameo’s portrait belong to some level’s own upcoming boss reveal?
Divingkn00t belongs to no level yet (explicitly “held for a bonus/underwater stage,”
PLAN.md:113) — nothing is spent using it here. This turned out to be the sharpest,
most checkable predictor of the other three’s failure (see below) — sharper than “does
the portrait look thematically right,” which is more a judgment call.
R4 — Scale. Small (12×12), blending into the background rather than reading as a foregrounded “boss preview” UI element.
3. Scoring the other three
| R1 Pun bridge | R2 Site specificity | R3 Hide/peek | R3.5 Not borrowed | R4 Scale | |
|---|---|---|---|---|---|
| Freezer (Lidl Lunch) | ✅ diving↔frozen | ✅ the freezer door | ✅ peeking from behind it | ✅ Diving is unclaimed | ✅ 12×12 |
| Clouds (Cyberkn00t) | ⚠️ weak — a robot has no established link to sky/flight | ❌ floating in open air, tied to nothing | ❌ fully exposed | ✅ Cyber previews this level’s own boss — not stolen from elsewhere | ❌ 24×24, the biggest of the four |
| Minigolf (in_black) | ❌ sunglasses+turtleneck has no golf logic beyond “sunny day, sunglasses,” which is generic enough to apply to any outdoor level | ❌ “standing at the tee,” no object tie | ❌ fully exposed, in-code comment literally says “watching from the sidelines” | ❌ in_black is Lidl Lunch’s own boss (PLAN.md:107) — Minigolf spends someone else’s reveal on a background extra | ✅ 12×12 |
| Soccer (open_hair) | ❌ no connection between “loose hair” and soccer | ✅ the goal net is a real, soccer-specific object | ⚠️ partial — net mesh does obscure him, closest of the three to R3 | ❌ open_hair is Tanzstunde’s own boss (PLAN.md:109) — same problem as Minigolf | ✅ 10×10 |
Reading the table: Minigolf fails all five checks — it is pure, unmotivated decoration, which matches Torsten’s read that it does nothing. Soccer partially passes two (a real object, a partial hide) — it’s the second-strongest of the three weak ones, let down almost entirely by portrait choice. Clouds fails on placement and scale but at least doesn’t steal from another level — its problem is almost entirely mechanical (floating in the open) rather than conceptual.
The R3.5 finding deserves its own callout because it wasn’t something I went looking
for — it fell out of just reading PLAN.md’s own level→boss table (lines 106-110)
against items.ts‘s cameo list. Both weak, portrait-driven cameos (Minigolf, Soccer)
reuse a different level’s real boss form as a throwaway background gag. That’s not
just “the wrong vibe” — it’s spending a future reveal’s surprise twice, on a level that
has no narrative claim to that character, for a joke that isn’t even about him. Cyberkn00t
in Clouds doesn’t have this problem: it’s Clouds’ own boss, glimpsed in an earlier
form before its real one — a legitimate tease, not a theft. That’s one reason I don’t
think Clouds needs a portrait swap as urgently as the other two; it mainly needs a
placement fix.
4. Redesign proposals
4a. Above the Clouds (Cyberkn00t)
Diagnosis: portrait is defensible (not stolen from elsewhere, “robot” isn’t an absurd fit for a level full of airplanes), but the placement — floating alone in open sky at a fixed point — has zero R2/R3. There’s nothing to hide behind, so there’s nothing to peek from, so it just reads as a UI-style “here’s a preview of the boss” callout rather than an environmental joke.
Proposal — attach him to a plane, not to a point in the sky.
Reframe Cyberkn00t as the mystery “autopilot” secretly flying one specific plane in the
existing patrol set — clouds.ts:84, the near-ceiling one (altitudePatrol('airplane', 800, 1050, 25, 55)). Small robot head peeking out of that plane’s cockpit corner,
riding its patrol back and forth rather than sitting fixed at x=700/y=40. This gets
you:
- R1 fixed: “a machine secretly pilots this level’s machines” is a real, checkable bridge, stronger than “robot happens to be near clouds.”
- R2 fixed: tied to a specific, already-existing hazard object (that plane), not floating in empty space.
- R3 fixed: peeking out of a cockpit window is the same hide/reveal shape as the freezer door.
- Season 2 payoff gets sharper too: instead of a static point becoming fatal, the plane the player has been dodging all level is the ambush — same sprite, same patrol, but now it actually connects on contact. “The friendly-looking robot you glimpsed was flying the thing trying to hit you the whole time” is a real punchline, not a stat check.
- Buildable with existing primitives:
kn00tCloudsAmbushalready just needspatrolFrom/patrolTo/patrolSpeedmatching that plane’s own values instead of a fixed x/y — the same patternseason2ExtraItemsalready uses elsewhere for moving ambushes.
Alternative, smaller change: keep him fixed but tuck him behind one of the cloud platforms (e.g. the 650-770 high platform) instead of in open air, so at least R3 is satisfied without touching the pun. Weaker but cheaper — worth it only if attaching to a moving plane turns out to be more engine work than it looks.
What I’d actually do: the plane version. It’s not meaningfully harder to build than the current fixed-point version, and it’s the only option that also improves the Season 2 payoff instead of leaving it alone.
4b. Minigolf (kn00tInBlack)
Diagnosis: the worst of the three — fails every rubric check, and it’s actively spending Lidl Lunch’s boss reveal on a level that has no story reason to feature him. “Sunglasses + black turtleneck, standing at the tee” isn’t a joke, it’s a background extra.
Proposal — swap to kn00t_1 (the unclaimed camo portrait), hide him in a sand trap.
kn00t_1’s grey/orange mask-and-camo collar reads as camouflage gear — hunting/paintball
attire. That’s a genuine pun for a golf course: camouflage is for hiding in rough
terrain, and this game’s own golf hazards are sand traps (minigolf.ts’s HazardDef
pits). Concretely:
- Move him from the open tee (x=60) to one of the level’s own permanent (
heals: false) pits — e.g. the one at x=970 or x=1150 (minigolf.ts:48-49) — far enough into the level that finding him isn’t a freebie at the spawn point. - Reuse the existing
hiddenUntilHazardfield (already built for the hidden-gallery food items,minigolf.tsandmain.ts) so the decoration only renders once that specific pit has revealed — mechanically identical to “peeking out from behind a door,” except the door is a sand trap the level already opens on approach. This is the same reveal-on-approach grammar the level already trains the player on with its pits, not a bolted-on new mechanic. - Pun chain: camo gear → made for hiding in rough/sand → he’s hiding in the one hazard a golfer actually has to hide a ball from. Two hops, same rigor as diving↔frozen.
- Season 2 payoff: the trap you’d already learned to time-jump over now also has him waiting in it — a genuine “the hazard you dodge all level was hiding something extra” reveal, not a random new hit.
Alternative: kn00t_3 (eyepatch, wide-brimmed hat) instead — the wide brim alone reads as a sun hat, which is on-theme for any outdoor level, but the eyepatch/leather jacket pull it toward “pirate,” which doesn’t connect to golf as directly as camo does to a sand trap. Weaker pun, keep as backup if camo reads wrong once actually pixelized at 12×12.
What I’d actually do: kn00t_1 in the x=970 or x=1150 pit, gated on hiddenUntilHazard.
It’s the strongest pun of the three redesigns and reuses machinery that already exists
in this exact level.
4c. Soccer (kn00tOpenHair)
Diagnosis: closest of the three to working already — the goal net is a real, soccer-specific object (R2 ✅) and net mesh does partially obscure him (R3, partial). The entire failure is the portrait: open_hair has no connection to soccer, and it’s Tanzstunde’s own boss, spent here for nothing.
Proposal — swap to kn00t_3 (eyepatch/pirate), pun on “goal” as loot.
“Goal” means two things at once here: the soccer net, and the thing a pirate is
guarding. An eyepatched kn00t lurking in the goal net isn’t just “some other guy in the
net” — he’s guarding the goal like treasure, which is exactly what a pirate does and
exactly what a soccer net is to the team trying to score (the footballGoal “GOAL!
+40” fanfare — soccer.ts, main.ts — already frames scoring as a reward-tier event,
same currency as a pirate’s loot). Keep the existing position (x=1544, in the net) —
that part already works, it’s the character in it that’s wrong.
- R1 fixed: “goal” as double meaning (net / prize) is a real pun, not a vibe match.
- R2 unchanged (already fine): still the goal net.
- R3 unchanged (already fine): still peeking through the mesh.
- R3.5 fixed: kn00t_3 is unclaimed — nothing stolen.
- Season 2 payoff: “the guy guarding the treasure doesn’t let you just walk up and take it” is a coherent, mean-in-a-good-way twist, same shape as the freezer.
Alternative: kn00t_2 (top hat, magician) as “makes your shots vanish” — a magician
secretly responsible for balls that don’t go in is a cute complementary joke about this
level’s own football-blocking/kicking mechanic (soccer.ts’s walk-blocks/run-kicks
split), but it’s a one-hop pun about mechanics, not about the object he’s standing
in, so it’s slightly less concrete than the pirate/treasure reading. Worth keeping in
reserve if the pirate look reads oddly small at 10×10.
What I’d actually do: kn00t_3, same net, same position — smallest change of the three redesigns (only the portrait moves) and the pun is the tightest.
5. Is the unbuilt “6-7” cameo worth building?
Opinion, not a rubric score — it’s a different comedic register from the other four by
design (“non-hostile easter egg,” no ambush twist, per PLAN.md:113 / docs/src/assets.md:35).
Yes, worth building — but not as a 5th in-level environmental cameo. Two reasons:
- It would compete with, not complement, the 4 existing gags if placed as a fifth sight-gag inside a level — same register, same “spot the kn00t” pattern, diminishing returns on a joke type the game already runs four times. The brief’s own worry about dilution is right.
- There’s a genuinely good piece of connective tissue sitting unused: kn00t_67 and EvilOriginalkn00t are the same costume (straw hat, rainbow shirt) — only the eyes differ (blue/harmless vs red/menacing). That’s a real “oh — it’s him, but now he’s dangerous” callback, but only if the player sees the harmless version first and remembers it by the time the final boss shows up.
Proposal: build it as a level-transition-screen gag, not world geometry — a
1-2 second appearance of the 6-7 idle-wave animation during the existing Soccer→
Tanzstunde level-complete transition (the same kind of scripted beat already built for
Underworld enter/exit, PLAN.md’s transition-vignette section). This:
- Matches “between levels 3 and 4” literally (Soccer is level 3, Tanzstunde is level 4
in
main.ts’sLEVELSorder) without inventing a placement inside either level’s geometry. - Costs much less than a new environmental cameo — no hiding spot, no pun-to-location logic needed, because it’s not pretending to be part of the world.
- Sets up the EvilOriginalkn00t reveal at the very end of the game as a real callback instead of a coincidence nobody notices, at zero risk of diluting the other four gags since it lives in a completely different moment (a loading screen, not gameplay).
If Torsten would rather skip it entirely, that’s also a reasonable call — the game doesn’t need a fifth gag to feel complete, and this is the only one of the five where “don’t build it” costs nothing. But given the straw-hat/rainbow-shirt payoff already sitting in the asset set unused, I’d lean toward building the transition-screen version specifically, not any in-level version.
6. Is the ambush-twist mechanic itself part of the problem?
Also opinion, per the brief’s fifth question. Not orthogonal — but also not broken.
The twist (harmless in Season 1 → guaranteed-fatal same sprite/position in Season 2,
items.ts:83-94) works because hiding/peeking (R3) already implies threat before the
twist ever fires: something ducking behind a door or lurking through net mesh already
reads as “watching you,” so flipping it fatal cashes a promise the composition already
made. Cyberkn00t floating in open sky and kn00tInBlack standing at the tee never made
that promise — nothing about “a guy standing around” suggests danger, so the fatal hit
on replay lands as an arbitrary stat check rather than a bit landing its punchline.
Practical implication: fixing the 3 setups (per §4) should be sufficient on its
own. The mechanic itself — same sprite, same position, value: 100, marked collected
after first hit (the softlock fix already shipped, PLAN.md:455) — doesn’t need to
change per level. It’s a sound piece of machinery that’s been paired with weak setups;
strengthening the setups should make the existing payoffs land without touching the
ambush code at all.
Mechanics Audit (2026-08)
Mechanics audit — hard-gate reachability + jump/obstacle-height physics (2026-08)
Requested after PLAYTEST-FINDINGS.md’s PF-4 (dressCodeGuard gated on
hasTailoredClothes, currently unwinnable-by-design if not for an accidental
jump-bypass, task #13 already dispatched to redesign that specific bypass into a real
telegraphed jump). This doc does not re-investigate or touch PF-4/task #13 — it
sweeps the rest of the game for the same two failure-mode classes that case revealed,
so the next one gets found here instead of on a TV during a playtest:
- Hard-gate reachability — every place player state conditionally blocks movement
(
moveSolids) or gates aPortalDef/goal, whether the required state’s source is guaranteed-reachable given the fixed one-way level order, and if optional/skippable, whether a real (even if currently accidental) way through exists. - Jump-height/obstacle-height physics — obstacle/hazard geometry vs. the jump
envelope (
GRAVITY=900,JUMP_VELOCITY=-300→ apex ≈50px above launch, airtime ≈0.667s →maxJumpGapPx(moveSpeed) = moveSpeed × 0.667, so ≈33px atWALK_SPEED, ≈53px atRUN_BASE, ≈93px at max speed tier).
Level order confirmed from main.ts: LEVELS = [MINIGOLF, LIDL_LUNCH, SOCCER, TANZSTUNDE, CLOUDS], LEVELS2 is the Season-2 parallel of the same five.
advanceLevel() only ever does levelIndex++; nothing in main.ts ever decrements it
or offers a way back to an earlier level. No backtracking exists anywhere, confirmed by
reading the code rather than trusting the brief.
Exhaustive moveSolids sweep: grepped every conditional addition to moveSolids in
main.ts. There are exactly two, in the whole game:
soccerBall(blocks only while!running; always resolvable — run into it to kick it away, or just jump over it, or walk around during its patrol swing). Not player-state gated, not a lock candidate.dressCodeGuard(PF-4, gated onhasTailoredClothes). The only genuine hard blocker in the codebase.
No other item, hazard, or state anywhere gets added to moveSolids. Every other
“obstacle” (discoBall, waterfall, lava, breakDancer, bat, dancePartner,
kn00t ambushes, poisonEgg, airplane) is a damage-on-touch item, never solid — tall
sizing on any of them (e.g. discoBall/waterfall at h:32) affects whether you can
dodge without taking a hit, never whether you can physically pass. This means the
jump-height audit’s practical scope is narrower than it might look: there is no second
hidden hard blocker anywhere with an unnoticed height problem, because there is no
second hard blocker at all.
Findings, ranked by severity
1. [Context correction, not a new bug] Tailored Clothes has two independent chances, not one — refines PF-4’s severity, doesn’t remove the need for task #13
What: PF-4 and the brief both state Tailored Clothes exists “in exactly one place
in the game.” That’s true of the LevelData object (underworld_mushroom_house.ts is
written once), but not true of player-reachable opportunities. Soccer’s secret door
portal exists on both SOCCER (Season 1) and SOCCER_S2 (Season 2):
// soccer_s2.ts
export const SOCCER_S2: LevelData = {
...SOCCER,
portal: SOCCER.portal && { ...SOCCER.portal, used: false },
...
};
This is the exact fix ARCHITECTURE-REVIEW-2026-08.md §1 made for the
PortalDef.used-aliasing bug: each season gets its own PortalDef object with its
own used flag, not a shared reference. The side effect (not a deliberate safety net,
just an emergent property of that fix) is that Season 2’s own playthrough of Soccer
(LEVELS2[2], immediately before TANZSTUNDE_S2[3] — the guard’s level) gets an
independently-fresh, un-used copy of the same portal, leading to the same
UNDERWORLD_MUSHROOM_HOUSE LevelData object — and since loadLevelData() always does
items = data.items.map(i => ({...i})), that second visit spawns a fresh, uncollected
copy of tailoredClothes regardless of what happened on the Season 1 visit.
Why it matters: a player who skips the Soccer secret in Season 1 is not yet in an unwinnable state — they get exactly one more shot at it in Season 2’s Soccer, the level immediately before the one with the guard. The real failure condition is “skip the Soccer secret door in both the Season 1 and Season 2 playthroughs,” not “skip it once.” That’s a materially different (still real, but rarer) risk than PF-4 describes.
Also checked while tracing this: uervelKnight (Lidl Lunch’s Season 1 pickup) gets
the same accidental redundancy for a different structural reason — it’s a plain item in
LIDL_LUNCH.items, not gated by any PortalDef, and LIDL_LUNCH_S2 spreads
...LIDL_LUNCH.items unconditionally, so it’s simply present again, uncollected, on the
Season 2 visit. Two independent chances, no lock risk — noted here only to confirm
it’s not a second instance of the problem below.
Fix direction: none needed here — this is a documentation/severity correction, not a code change. Worth folding into task #13’s writeup or PLAYTEST-FINDINGS.md PF-4 so whoever picks up the redesign has the accurate risk model (still worth fixing properly — “must fail identically twice” is still a real, if smaller, unwinnable-state risk, and the task is already about making the intended path reliable rather than relying on either chance-taking or the jump accident).
2. Soccer Shoes and Dance Shoes are single-chance items with no season-pair redundancy — currently soft-gated, but the redundancy that saves Tailored Clothes does not apply to them
What: soccerShoes (lidl_lunch_s2.ts) and danceShoes (tanzstunde_s2.ts) are
each defined only in their level’s _s2.ts file — there is no Season 1 counterpart
item at all (Season 1’s LIDL_LUNCH/TANZSTUNDE never mention them). Since Season 2
of any given level is only ever visited once in a full run (no backtracking, no Season
3), each of these items gets exactly one opportunity, not two. Contrast with finding
#1: Tailored Clothes’ redundancy comes specifically from existing on a level
(SOCCER/SOCCER_S2) that has a Season 1 twin sharing the same portal-to-secret
pattern; these two equipment items don’t have that structural protection because their
whole level variant is Season-2-exclusive.
Why it’s not currently a bug: both gates today are soft, not hard blockers:
soccerShoesgates only whether a kicked/walked-intofootballGoalscores (main.tslines ~1106, ~1208) —def.kind === 'food', never added tomoveSolids. Missing it just means the goal stays inert;boss.checkReached()(the only thing that actually ends a level, perboss.ts) has zero awareness offootballGoalat all.danceShoesgates only whether adancePartner’s downbeat dip becomes a bonus (player.danceMove()) instead of a hit (player.takeHit()) — never a blocker either.
Missing either one just means “take the hit / miss the bonus points,” never “cannot proceed.”
Why it’s still worth flagging: the game’s own established pattern (see
items.ts’s comment on ItemKind: 'equipment': “Soccer Shoes now, Dance Shoes/tailored
clothes expected to follow the same pattern”) is exactly the pattern that produced the
real hard-block case (dressCodeGuard). If either of these two ever gets escalated
from a soft bonus-gate into a real moveSolids hard block — the same escalation that
apparently already happened once for Tailored Clothes — it would be a worse version
of PF-4, not an equal one: no season-pair redundancy exists to fall back on, so a single
missed pickup would be a true, un-mitigated softlock from the moment it shipped, not
one accidentally saved by a jump exploit or a second season-2 chance.
Fix direction: no code change needed today (nothing is broken). If either
soccerShoes or danceShoes is ever turned into a hard gate later, budget for either
(a) a second, independent pickup location, or (b) explicitly accepting single-chance
risk with a clearly reliable (not accidental) path to it, the way task #13 is already
doing for the guard. Worth a one-line note wherever the “equipment gates a mechanic”
pattern gets extended (items.ts’s own comment is the natural place).
3. Minigolf’s healable pit-4 fill-rect overlaps the secret crawlspace floor it sits above — geometry oddity, not a lock (escape route always exists)
What: Minigolf’s hidden crawlspace under the “cup” (pit 3, x=700-730,
heals:false, the permanent secret entrance) has a floor (TUNNEL_FLOOR_Y=213,
TUNNEL_FLOOR_W=140 → spans x=640-780) with no roof past x=700 (the thin
TUNNEL_ROOF_H crust only covers the approach side, 640-700, by design — see the
file’s own comment). Pit 4 sits at x=762-792, heals:true, and its own hazard
fill-rect is the full ordinary ground thickness: {x:762, y:GROUND_Y(192), w:30, h:40}
— i.e. it spans y:192-232.
The crawlspace floor (y:213-216) at x:762-780 sits entirely inside that fill-rect’s
vertical span (192-232). Pit 4 reveals (opens) purely by player.x proximity — the
reveal check in main.ts (player.x + hazardRevealDistance >= hz.x) has no y
component, so it fires the same whether the player is walking the surface or already
underground. In ordinary forward-walking play, both pit 3 and pit 4’s reveal thresholds
(700-130=570/762-130=632 in Season 1; tighter in Season 2) get crossed together well
before the player reaches either pit, so pit 4 is already open by the time anyone falls
into pit 3’s hole.
The actual issue: if the player dawdles underground (there’s food to collect at
x=648-689, plenty of reason to linger) for more than HAZARD_HEAL_DELAY=2.0s past
pit 4’s reveal, pit 4 re-heals — and its solid fill-rect reappears exactly where the
crawlspace floor’s own footprint (762-780) is. Two sub-cases:
- Player already moved on, not standing there when it re-seals: the crawlspace
simply becomes impassable further east from that point on — a dead end. Harmless:
nothing of value is placed east of
x=689underground (the only underground loot, the hidden food cluster, sits west of pit 3’s own opening), and pit 3’s hole (heals:false, permanent) is always available to climb back up and out to the west. - Player is standing exactly in that footprint when it re-seals:
moveAndCollide(engine/collision.ts) resolves collisions by re-checking overlap after applying the frame’s owndx/dy, with no “was this already overlapping before this frame” check. Vertically this is self-correcting in practice —vyis essentially always slightly positive from gravity even when “standing still,” so thedy>0branch fires and snaps the player up to stand on the newly-solid rect’s top (y = s.y - box.h), the same “land safely” behavior the dutyCycle hazards’ code comment describes (that comment is written for the Season 2 rhythmic-pit case specifically, but the underlyingmoveAndCollidemechanics happen to produce the same safe outcome here too). Horizontally it’s less clean — ifdx≠0at that exact instant, the player gets snapped to just outside the rect’s edge in the direction implied bydx’s sign, which can read as a sudden, disorienting teleport rather than a smooth collision, though never a stuck-forever state.
Severity: Low. No path to an unwinnable state exists — pit 3’s permanent hole is always a way out, and there is nothing to miss by not going further east. Worst case is a startling snap/reposition if the timing lines up exactly, or a dead-end wall with nothing behind it.
Fix direction: either exempt hazards whose fill-rect footprint overlaps a portal’s
own destination-adjacent geometry from healing (cheapest: give pit 4 heals:false too,
matching pit 3/5/6/7’s already-established “hazards near a secret stay open forever”
convention — pits 1/2 are the only ones far from the tunnel and are the only ones that
still make sense as healable), or narrow TUNNEL_FLOOR_W so the crawlspace floor never
extends under a healable pit’s own x-range in the first place. Either is a one-line
change; not attempted here per this doc’s read-only scope.
4. Tanzstunde S2’s teach-portal and Clouds S2’s Upperworld portal are walk-in on the only ground path — structurally “optional secret,” behaviorally near-mandatory
What: Both TANZSTUNDE_S2.portal (trigger x=1795, y=176, w=16, h=16, no
requiresSneak) and CLOUDS_S2.portal (trigger x=1560, y=164, w=20, h=16, no
requiresSneak) sit directly on their level’s sole continuous ground/platform path,
with no crouch-gate distinguishing “walking past” from “entering.” Compare with
LIDL_LUNCH.portal (requiresSneak: true — walking upright through the trigger zone
never fires it) and SOCCER.portal (only reachable by a deliberate jump onto an
elevated crate well off the main ground line) — both genuinely skippable by ordinary
forward movement. A player would have to deliberately time a jump so their entire
hitbox clears y:176 (Tanzstunde) or y:164 (Clouds) while horizontally inside a
16-20px window, with no in-game reason to attempt that jump at all, to avoid either
portal. In practice, walking normally triggers both every time.
Why it’s not a bug: neither portal gates anything required for progression — the
teach room (underworld_tanzstunde.ts) and the Upperworld recall room
(upperworld_clouds.ts) are both reward/lesson content, not equipment sources, and
boss.checkReached() never checks anything from either. If anything, the teach-portal
being effectively mandatory is a small, accidental mitigant for PF-3 (dance lesson not
looping/being missable) — a player is unlikely to skip the lesson by accident, unlike
missing a genuinely optional secret.
Severity: Informational/Low. Worth naming because it reads structurally identical
to the Lidl Lunch/Soccer secrets (a PortalDef on a level) while behaving completely
differently (unavoidable vs. genuinely optional) — a future author reusing the “secret
portal” pattern for something with real stakes (equipment, a hard gate) should know
requiresSneak or off-the-ground-path placement is what actually makes a portal
skippable, not merely being a PortalDef.
Fix direction: none needed for correctness. If PF-2’s “portals too close to the
boss” repositioning work happens, worth deciding at the same time whether these two
should stay walk-in-mandatory (arguably correct for the teach portal specifically) or
gain requiresSneak/off-path placement to genuinely match “secret to find” framing.
5. Underworld Mushroom House’s lava pool can produce a slow multi-hit knockback grind at low speed tier — by design, but worth naming as a friction risk
What: lava (w:105, h:10) is deliberately sized, per its own items.ts comment,
“close to the minimum that still blocks a max-speed-tier jump (~93px)” rather than
padded wider. The same comment already flags the tradeoff: takeHit()’s knockback
(HURT_KNOCKBACK=90px/s, opposing the direction just traveled) “fights forward progress
through any hazard wider than one hit’s recovery distance,” and a decisive full-speed
run is what avoids “a slow multi-hit grind… at walk speed instead.” A player who
reaches this secret room early (Soccer, level 3 of 5) without ever picking up River Cola
speed-tier boosts is running at RUN_BASE=80px/s at best, well under the max-tier speed
the pool’s width assumes, and can get knocked back into the pool’s own start repeatedly.
Why it’s not a lock: every hit just costs energy and knockback; running out of
energy triggers player.respawn(true) (full energy, same spot in the level, hazards
reset) rather than any permanent block. Worst case is a frustrating multi-attempt grind,
not an unwinnable state — and the room already places a River Cola pickup
(x:60) before the lava specifically so a fresh attempt can rebuild some speed first.
Severity: Low, informational. Flagged only because it’s exactly the shape the physics-audit brief asked to check (obstacle width sized against the jump/speed envelope) and the developer’s own comment already names the tradeoff as a known compromise rather than an oversight — recorded here so it’s not rediscovered as new.
Fix direction: none needed; if it ever becomes a real complaint, the standard levers
are a shorter knockback distance for this specific hazard (ItemDef.knockback override,
already supported) or moving the River Cola pickup closer to the pool’s entrance.
Summary table
| # | Finding | Category | Severity |
|---|---|---|---|
| 1 | Tailored Clothes has 2 independent chances (Season 1 + Season 2 Soccer), not 1 — corrects PF-4’s framing | Hard-gate reachability | Context correction (informational) |
| 2 | Soccer Shoes / Dance Shoes are single-chance, soft-gated today, would be a worse-than-PF-4 lock if ever hard-gated | Hard-gate reachability / latent risk | Medium (latent) |
| 3 | Minigolf pit-4 heal-rect overlaps the secret crawlspace floor’s footprint | Jump/obstacle-height physics | Low |
| 4 | Two Season 2 portals are walk-in-mandatory despite reading as optional secrets | Hard-gate reachability | Low / informational |
| 5 | Mushroom House lava pool can grind at low speed tier | Jump/obstacle-height physics | Low / informational |
No second genuine hard-block softlock (the dressCodeGuard/PF-4 class) exists anywhere
else in the game — the moveSolids sweep was exhaustive and found only the one already
known and dispatched.
6-7 Gag Research (2026-08)
The 6-7 gag as a “Meanwhile…” interlude — build-ready draft (2026-08-08)
Follow-on to CAMEO-COMEDY-2026-08.md §5, which already settled the what: build
kn00t_67.gif’s unbuilt “6-7” easter egg as a level-transition-screen gag, not a
5th in-level environmental cameo, timed to the existing Soccer→Tanzstunde level
advance. This doc takes that one paragraph and turns it into something buildable:
what’s actually on screen, what the sting sounds like, which state machine it hooks
into, and how it relates to docs/src/story.md’s “Meanwhile…” narrative device and
the sibling rickroll-finale thread (FINALE-DESIGN-2026-08.md, NEXT.md). Same
spirit as the other three 2026-08 docs — a set of concrete options for Torsten to
react to, nothing here is decided. No code changes, no asset generation — this
doc only.
1. What’s actually in the asset today
Opened both frames directly (assets/src/kn00t/kn00t_67.gif, coalesced), not going
by name alone:
- 200×200, 89a GIF, 2 frames, 32-color palette.
- Straw hat, brown beard/mustache, blue eyes (the harmless twin of EvilOriginalkn00t’s red-eyed “same costume” per CAMEO-COMEDY §1), rainbow-striped shirt visible at the shoulders/chest.
- The animated part is small: both hands sit at chest height holding a bit of the rainbow shirt fabric; frame-to-frame the hand position shifts slightly — read as an idle “hey, over here” wave, not a big arm swing. It’s a subtle loop, not a broad gesture — worth knowing going in, since “2-frame idle wave” undersells how little actually moves.
- Not yet pixelized.
assets/gen/kn00t/has outputs for all 8 gdrive portraits (pixelize_kn00t.sh) pluscamo/pirate(pixelize_kn00t_extra.sh, for kn00t_1/kn00t_3), but nothing for kn00t_67. This is a real, first build step, not a formality — see §2.
2. Visual elaboration
2a. Extracting the two frames (new tooling step, following the existing pattern)
kn00t_1/kn00t_3 needed pixelize_kn00t_extra.sh because their source webps are
non-16-divisible upscales of a native pixel-art grid (854×858 → 16×16, a documented
non-integer-block-boundary case solved with -filter point). kn00t_67 is a cleaner
version of the same problem: 200×200 is an exact ×10 scale of a 20×20 native grid
(confirmed by test-resizing frame 0 to both 16×16 and 20×20 with -filter point —
20×20 preserves the eyes/mustache detail more cleanly than 16×16, which is expected
since 200/20 = 10 exactly vs. 200/16 = 12.5). This means kn00t_67’s own native grid
(20×20) doesn’t match the rest of the kn00t family’s 16×16 — worth flagging as its
own visual fact, not a mistake to correct: the family isn’t pixel-grid-uniform to
begin with (kn00t_1/kn00t_3 are also drawn on a different physical resolution than
the gdrive set, just documented rather than pixel-perfect-matched).
Proposal: a new tools/pixelize_kn00t_67.sh, same shape as the other two scripts —
convert kn00t_67.gif -coalesce to split the 2 frames, then per frame: flood-fill
white background to transparent from all 4 corners (same -fuzz 8% recipe already
proven on this exact source family), -filter point -resize 20x20!, plus an _hd.png
upscale for any UI use that wants it bigger than native. Output
assets/gen/kn00t/sixseven_a.png / sixseven_b.png (frame naming, not wave_1/
wave_2 — keeps it visually distinct from the ambush cameo family’s open_hair-style
descriptive names, since this one isn’t a costume identity, it’s an animation frame
pair).
2b. Making a 1-2s screen moment read as staged, not a gif drop-in
The raw asset is two static frames of a small, low-contrast gesture. Enlarging it and flipping frames for 1-2 seconds, alone, risks reading as exactly what CAMEO-COMEDY was worried about — “a picture appeared for a second,” not a beat. Proposals, layered (not exclusive — pick the subset that’s worth the build cost):
- Scale it up a lot. At native 20×20 it’ll disappear on a 384×216 canvas
(
VIEW_W/VIEW_H). The other cameos work small because they’re meant to blend into level geometry (CAMEO-COMEDY’s R4). This is the opposite case — it’s the only thing on screen, so it should be the opposite of subtle. A_hd.png-scale render (say, 120-160px tall, roughly a third to half the canvas height) centered or slightly off-center reads as a deliberate portrait, not a sprite. - A held background, not a black screen.
drawBackdropSequence()already exists as a generic “hold a background, crossfade, caption” primitive (used for the gameComplete montage and title/demo attract mode) — reusing it here means the gag gets a moving-parallax backdrop (Soccer’s or Tanzstunde’s ownbgNear, or a plain solid color) essentially for free, rather than a flat fill behind the character. Concretely: a short, non-looping call into the same drawing code, holding on one frame instead of cycling levels. - Procedural motion beyond the frame-swap, staying inside the project’s
no-external-art-pipeline rule (
tools/’s scripts are all ImageMagick shell pipelines or numpy-driven Python, no hand-drawn frames beyond what’s already sourced):- A slow sine-driven vertical bob (few px, ~1Hz) on top of the 2-frame swap —
same “cheap procedural life” trick already used elsewhere in this engine for
idle animation (e.g. the dance partner’s beat-driven sway,
danceDemoBeatProgress). - A radiating “ta-da” burst — a few small expanding circles/stars drawn with plain
ctx.arc()calls timed to the sting’s hits (no new asset, matches the game’s existing habit of hand-coded Canvas2D flourishes rather than sprite-sheet FX, e.g. the confetti/sparkle-style elements implied bychest_open’s sparkle SFX pairing). - A simple vignette or radial spotlight (reusing the existing
startTransition()vignette math,VIGNETTE_MAX_R, just as a static spotlight rather than an animated close/open) to frame him rather than pasting him on a flat rect.
- A slow sine-driven vertical bob (few px, ~1Hz) on top of the 2-frame swap —
same “cheap procedural life” trick already used elsewhere in this engine for
idle animation (e.g. the dance partner’s beat-driven sway,
- Caption/text overlay.
docs/src/story.mdnames the device “Meanwhile…” but that literal word has never actually been rendered anywhere in the shipped game — every other overlay speaks in-world text (LEVEL COMPLETE,kn00t blocks the way...,RECALLED!). This would be the first real use of the narrative label itself. Two options, genuinely open (see §6):- Literal:
"Meanwhile..."as a top caption, establishing the device by name for the first time — the FINALE/rickroll payoff, whenever it’s built, would then be read as another instance of an established pattern rather than each interlude inventing its own text convention. - In-world: something that plays the “6-7” internet-meme joke straight without
naming the device, e.g. a small
"...6"/"...7"split across the two frame beats, or nothing at all — letting the 2-frame wave be the entire joke, silent and small, on the theory that over-captioning a meme reference kills it.
- Literal:
What I’d actually do: scale it up (2a), hold it against a level backdrop via
drawBackdropSequence rather than a flat fill, and skip new procedural FX beyond
maybe the sine bob — the sting (§3) is doing most of the “this is a real beat” work,
and a family-audience meme cameo doesn’t need much visual decoration to land. Caption
choice is genuinely open, flagged in §6.
3. Music: a short original sting
What’s already there
Two existing tools, two different registers:
tools/make_sfx.py— short (~0.1-1.5s) one-shot stings vianote()/sequence()/noise()+ a simple attack-decayenvelope(), written straight toassets/gen/sfx/{name}.wav, wired into a fixedSfxNameunion (src/engine/audio.ts) and played viaplaySfx(name)— genuinely one-shot, no looping. This is where every existing moment-scale sting lives:ambush_reveal(a dissonant stab + descending “cackle” for the kn00t ambush-twist reveal — the closest existing sibling to this gag, both being a “kn00t reveal” beat),chest_open(creak + ascending sparkle),goal(whistle + fanfare),level_complete(4-note ascending fanfare).tools/make_music.py— full step-sequenced loops (bass/chords/melody/percussion viarender_voice(),kick(),hihat(),mix()), written toassets/gen/music/{name}.wav, played viaplayMusic(name, layer?). Looping is hardcoded —startMusicNow()always setssrc.loop = true; there is no one-shot playback path through the music system today. This file also holdsbytebeat(), the raw integer-formula synthesis technique explicitly earmarked for the actual rickroll joke sound (NEXT.md’s rickroll section,season3_exploratory’sbytebeat_1/2/3.wavR&D sketches) — a different, deliberately “broken-sounding” register from what this gag wants.
The 6-7 gag’s sting is a 1-2s one-shot, timed to a specific on-screen moment — that’s
exactly the make_sfx.py shape (ambush_reveal, chest_open), not the
make_music.py shape (which would need new one-shot plumbing in audio.ts to even
play back without looping).
Proposal
Add make_sixseven_wave() to tools/make_sfx.py’s main(), saved as
assets/gen/sfx/sixseven_wave.wav, added to SFX_NAMES in src/engine/audio.ts.
Musically: the existing stings split cleanly into “triumphant” (goal,
level_complete — clean ascending diatonic runs), “sneer” (ambush_reveal —
dissonant beating tones + descending cackle), and “treasure” (chest_open — creak +
bright sparkle). None of those registers fit a friendly, goofy meme cameo — it should
sound silly, not victorious or sinister. Concretely: a simple two-phrase “call and
answer” matching the two animation frames — e.g. a bright rising interval on frame A
(note() with a short, wide-duty pulse for a slightly reedy/kazoo-ish timbre,
matching the straw-hat/rainbow-shirt “beachy” read rather than this game’s usual
tight 8-bit chiptune duty cycle) answered by a droll descending “womp womp”-adjacent
two-note close on frame B — total length matched to the on-screen hold (~1.2-1.8s,
in DANCE_FANFARE_DURATION’s ballpark, not ambush_reveal’s shorter ~0.3s stab).
Open question (see §6): whether to keep it purely inside make_sfx.py’s simple
note()/sequence() primitives (cheapest, matches the sting-family precedent
exactly) or borrow make_music.py’s richer multi-voice render_voice()/mix()
composition for a fuller little “jingle” feel (closer to what “compose some music”
suggests, but means either duplicating a couple of helper functions across the two
tool files or importing between them, which the existing tools don’t do today — they’re
independent, single-purpose scripts). Leaning toward the simpler make_sfx.py path
unless a richer jingle is worth that plumbing.
4. Input-blocking mechanic
What exists today (checked directly in src/main.ts)
update() is one large function that branches on state near its top, and every
non-'playing' branch returns immediately after input.endFrame() — this is the
actual mechanism by which the game already blocks player control during a scripted
beat, not a separate flag:
state === 'won'— the LEVEL COMPLETE screen (drawWinOverlay). Set the instantboss.checkReached()fires (line ~1656); the just-finished level’s own data is still loaded (level.bossSpriteis why the boss portrait shown here is that level’s own boss, not the next level’s). Onlyinput.jumpPressed()does anything, callingadvanceLevel(). No timer — it waits indefinitely for a press.state === 'transition'— the vignette closing/opening beat wrapped aroundenterUnderworld()/exitUnderworld()(startTransition(),TRANSITION_CLOSE_S/TRANSITION_OPEN_S= 0.5s each). Fully scripted, timer-driven, no input read at all during the beat. Hardcoded to the Underworld swap —tr.kindis only'enter' | 'exit', and the phase-complete branch callsenterUnderworld(tr.destination!)/exitUnderworld()directly, not a generic “run this content” hook.state === 'dancing'— the Simon-Says QTE (updateDanceSequence), also fully taking overupdate().state === 'gameComplete'— the finale screen; also blocks normal gameplay, though (uniquely) it still readsjumpPressed()for a couple of live choices (start Season 2, reset idle timer).
There is no generic “play a scripted, timed, input-blocking beat with custom
content, then resolve to X” state. PLAN.md’s own P5 line item (“prequel/interlude/
sequel cutscene player, caption system, narration slots”) has never been built —
confirmed by PLAN.md:314’s explicit note that the Season 1→2 celebration interlude
was skipped for exactly this reason, and by the two video assets earmarked for
exactly this purpose (VID-20210802-WA0000.mp4 prequel,
VDSL_Video_1622044168870.mp4 “Meanwhile…” interlude — docs/src/assets.md) sitting
completely unwired into the game to this day. drawBackdropSequence() is the closest
thing to reusable interlude rendering machinery that exists, but it’s a rendering
helper called from inside drawGameCompleteOverlay()/the title screen, not a state
of its own with its own update-loop gating.
What this means for the 6-7 gag
Say it plainly, per the brief: this generic mechanism doesn’t exist yet. Building
it just for the 6-7 gag (e.g. bolting a one-off timer onto the 'won'→advanceLevel()
path, conditioned on level.id === 'soccer') would work for this one gag, but the
brief’s own framing — “both are interludes in the ‘Meanwhile…’ storytelling style,”
both need to block input — plus the rickroll payoff being explicitly the next
thing in this same design family (still unbuilt per FINALE-DESIGN-2026-08.md’s
scope note: triggerRickrollStub() is a no-op today) means a one-off would likely
get half-rebuilt again in a few weeks for the rickroll animation. Proposal: add one
new generic state, once, sized for both.
Sketch (naming/shape, not final): a state === 'interlude' branch, structurally a
sibling to 'transition' — timer-driven (intervalT/duration fields, same shape as
transition’s t/phase), takes a small descriptor object (which draw function to
call each frame, how long to hold, what to do on completion — a callback or a
next-state string) rather than hardcoding “Underworld swap” the way 'transition'
does. On completion it calls whatever was passed in — for the 6-7 gag,
advanceLevel()’s normal loadLevel(levelIndex) (Tanzstunde’s own state already
becomes 'playing' immediately per loadLevelData’s convention; the interlude would
run as an extra beat inserted between advanceLevel() finishing its load and the
game actually handing control back) — for the rickroll payoff, whatever the eventual
8-bit animation + sting sequence needs. Both content payloads (6-7’s two-frame wave +
sting, rickroll’s animation + music) are just different data fed into the same
timed-blocking-state machinery, matching the “build it once for the interlude type in
general” instruction directly.
This also gives docs/src/story.md’s “Meanwhile…” concept and the never-wired
prequel/interlude video assets a real home for the first time — worth a one-line
flag to whoever picks this up that a generic 'interlude' state is also the thing
PLAN.md’s P5 line item has been waiting on since the original design brief, not a
bespoke solution invented just for a meme cameo.
Where exactly to splice it in for Soccer→Tanzstunde specifically (the placement
question CAMEO-COMEDY-2026-08.md §5 left implicit): after advanceLevel()’s
loadLevel(levelIndex) call, conditioned on the level just left being Soccer
(activeLevels[levelIndex - 1].id === 'soccer', checked once, since advanceLevel()
already runs for every level transition and only this one pair should trigger it) —
override the state loadLevel just set to 'playing' back to 'interlude' for the
gag’s duration before really handing control back. This keeps PLAN.md:209’s
existing rule intact (“normal level-to-level advancement… stays instant”) for every
other level pair, making Soccer→Tanzstunde a deliberate, narrow exception rather than
a general behavior change.
5. Narrative framing
docs/src/story.md’s “Narrative Sequences” section names the device directly:
“Short prequel, sequel, and interlude (‘Meanwhile …’) graphical sequences drive the
narrative.” Two things earmarked for exactly this role
(docs/src/assets.md/PLAN.md’s asset table) have sat unbuilt since the original
design brief. The 6-7 gag, built as proposed here, would be the first shipped
instance of this narrative device — not a one-off cameo gag wearing “Meanwhile…”
as a label of convenience. That’s worth being deliberate about, because whatever
shape it takes here (state name, timing convention, caption style) becomes the
precedent the rickroll payoff and any future prequel/sequel beat inherits, whether or
not that’s stated explicitly at build time.
Relationship to the finale/rickroll thread specifically: FINALE-DESIGN-2026-08.md
and NEXT.md’s rickroll section describe a much bigger sibling — a full 8-bit
disco stage payoff, gated behind the Tanzstunde-taught dance sequence recalled at a
trove in the Clouds “Upperworld,” already merged as a mechanic
(recallSucceeded/triggerRickrollStub()) but with its actual animation+music
payoff still 100% unbuilt, blocked on sourcing royalty-free-cleared audio. The 6-7
gag is comedically and mechanically the small version of the same idea: a
kn00t-costume callback (harmless-now/dangerous-later, same as
kn00t_67/EvilOriginalkn00t) landing as a “Meanwhile…” beat that blocks input for a
couple of seconds. Sequencing-wise, since the 6-7 gag is far smaller in scope
(no royalty-free audio blocker, existing assets, no new game mechanic — just a
timed cutscene state) it’s the natural one to build first, both because it’s ready
to build now and because it would validate the generic 'interlude' state (§4)
before the much heavier rickroll payoff has to lean on it. That sequencing note is
mine, not Torsten’s — flagged as an assumption in §6.
6. Open questions (flagged, not guessed at)
- Caption text. Literal
"Meanwhile..."(establishes the device by name, consistent going forward) vs. no caption / an in-joke caption vs. something else entirely. See §2b. - Sting composition path. Simple
make_sfx.py-stylenote()/sequence()sting (cheap, matches every existing sting’s precedent) vs. borrowingmake_music.py’s richer multi-voice composition for a fuller “jingle,” which has no existing cross-file precedent to build on. See §3. - Build order relative to the rickroll payoff. This doc assumes 6-7 goes first
and the generic
'interlude'state gets validated on the cheaper gag before the rickroll animation leans on it — that’s an inference from scope size, not something Torsten has said directly. Worth confirming, especially since a concurrent agent is researching the rickroll animation/music in a separate worktree right now; the two docs should ideally agree on the shared'interlude'state’s shape rather than each proposing its own, and that reconciliation is explicitly out of scope for this pass per the brief. - Exact on-screen duration and vertical placement. “1-2 seconds” is the brief’s own range; nothing here pins it to a specific number, nor to where on the 384×216 canvas the enlarged portrait sits (centered vs. off to one side, e.g. peeking in from a screen edge the way a full-body character-select portrait might).
- Whether the gag should be skippable. Every other input-blocking state in
this game either has no skip (the vignette transition, the fixed-length dance
demo phase) or resolves on a specific press (
'won'’s jump-to-advance). A 1-2s beat is short enough that “always plays, never skippable” is probably fine, but it’s worth deciding on purpose rather than by default, especially since a family audience will see this exact transition on every single Season 1 and Season 2 playthrough (nothing currently proposes gating it to first-time-only).
Rickroll Audio (2026-08)
A more literal rickroll audio cue — legal research + compositional brief (2026-08-08)
Status: research + plan only. No audio written, no code touched. Companion to
NEXT.md’s “The rickroll gag” section and assets/gen/music/season3_exploratory/README.md
(the bytebeat_*.wav sketches, currently the safe fallback wired into the actual build
happening in parallel to this doc). This doc asks a narrower question: could the payoff
sound more like “Never Gonna Give You Up” — evoke it more directly than a deliberately
alien bytebeat — while staying legally sound? Short answer up front: yes, via a
production-style pastiche with an original melody, and it’s a real net upgrade over the
bytebeat for basically zero added legal exposure — see the recommendation at the end.
1. Legal research findings
1.1 The core distinction: melody/lyrics vs. everything else
US copyright law protects a musical work’s expression — melody, lyrics, and (per some courts) a sufficiently original selection and arrangement of otherwise-common elements. It does not protect tempo, key, genre, instrumentation choices, or common chord progressions on their own:
“Copyright generally does not extend to abstract musical concepts such as musical styles, genres, grooves, common rhythms or chord progressions… common musical elements — such as chord progressions, tempos, hook phrases, and syncopation — are not individually protectable under copyright, either because they are not original or because they are ‘indispensable and naturally associated with the treatment of a given [musical] idea.’” — Never Going Out of Style: How AI-Generated Music’s Reliance on Style-Based Prompts Calls for a Reminder that No One Can Copyright a Musical Genre, ABA Entertainment & Sports Lawyer (2025)
This is the scènes à faire doctrine (elements standard/necessary to a genre aren’t copyrightable) combined with the idea/expression dichotomy (you can’t copyright an idea, only a particular expression of it) — see the case summaries collected in Un-Blurring Substantial Similarity, NYU JIPEL and the general treatment at BitLaw: Works Unprotected by Copyright Law. The one caveat worth flagging: courts (e.g. the Gray v. Perry “Dark Horse” litigation, see the Lexology write-up) have sometimes let plaintiffs argue that a particular combination of otherwise-generic elements is itself protectable if it’s sufficiently distinctive — so “just tempo and instrumentation, nothing else in common” is the safe target, not “everything except the exact notes.”
1.2 Two separate copyrights, and what “sound-alike” means in US law
A commercial recording carries two copyrights: the composition (melody, lyrics, chords as written) and the sound recording (that specific performance/production). US law explicitly carves out sound-alikes at the sound-recording layer:
“The concept of ‘sound alike’ recording is explicitly allowed in Section 114(b) of the U.S. Copyright statute, which states that the exclusive rights in a sound recording do not extend to making another sound recording that consists entirely of independent sounds, even if those sounds imitate or simulate the copyrighted recording.” — Sound-alikes — all you need to know, ClickNClear (see also the Wikipedia summary and 17 U.S.C. §114(b) itself)
But that §114(b) carve-out is about the recording, not the composition. A sound-alike that also reproduces the actual melody and lyrics still needs a license for the composition (this is why “soundalike” jingles in ads still pay mechanical/sync fees when they reuse the real tune — see the ClickNClear and Wood Law Offices overviews). This project’s plan sidesteps that entirely by not reproducing the melody or lyrics at all — see §2 below. Composition-copying is the one thing that actually requires a license or an exception; production-style-copying does not.
1.3 Backstop exceptions (useful, but secondary to just not copying the melody)
Even though the plan in §2/§3 is designed to not need an exception, it’s worth knowing the backstops exist, because Torsten works from Germany and the artist/song is UK in origin:
- US fair use / parody — Campbell v. Acuff-Rose Music (1994) held a parody’s commercial nature doesn’t defeat fair use by itself; 2 Live Crew’s “Pretty Woman” parody, which did copy the original’s music and opening line, was protected because it was transformative commentary on the original. (Oyez case summary, Justia opinion text). Relevant here mainly as evidence that even a much more aggressive reference than what’s proposed below has real legal cover in the US — this project’s plan needs less protection than Campbell’s facts, not more.
- German UrhG §51a (pastiche) — added 2021 implementing the EU DSM Directive, explicitly covers caricature, parody, and pastiche, and the legislative materials name memes, GIFs, mashups, and internet-culture remixing as intended use cases — a closer fit for “a game referencing a famous internet meme” than classic parody doctrine is. (Kluwer Copyright Blog: The dawn of pastiche, statute text via copyrightexceptions.eu). Relevant because Torsten is based in Germany.
- UK CDPA s.30A (parody/pastiche fair dealing) — in force since October 2014, “fair dealing with a work for the purposes of caricature, parody or pastiche does not infringe copyright.” (legislation.gov.uk text, Fieldfisher overview). Relevant because Astley and the song are UK in origin, even though UK law isn’t the most likely forum for a dispute over a hobby project.
The point of listing these: they’re a real, checkable safety net if anyone ever argued the pastiche brief below “evokes too much.” They are not the primary compliance strategy — the primary strategy is simpler and stronger: don’t copy the one thing that’s protected (melody + lyrics), and the exception question mostly never has to come up.
1.4 A separate risk that’s easy to miss: voice, not melody
If the payoff ever adds a sung vocal, there’s a distinct legal track that has nothing to do with copyright: right of publicity / voice misappropriation, for imitating a real person’s distinctive voice closely enough that listeners think it’s them.
Ford Motor Co. hired a former Bette Midler backup singer and told her to “sound as much as possible like the Bette Midler record”; the Ninth Circuit held this was actionable — a person’s distinctive voice is protectable property under California’s right of publicity, separate from any copyright question. — Midler v. Ford Motor Co., 849 F.2d 460 (9th Cir. 1988)
Frito-Lay hired a Tom Waits soundalike for a Doritos radio ad after Waits had publicly refused to do commercials; the jury awarded $2.375M for voice misappropriation and false endorsement. — Waits v. Frito-Lay, Inc., 978 F.2d 1093 (9th Cir. 1992)
Both cases were commercial advertising implying the celebrity’s endorsement of a product — a meaningfully different context from a non-commercial joke inside an indie game that isn’t claiming Astley endorses anything. But the underlying principle (imitating someone’s actual vocal timbre/delivery is a separate legal exposure from imitating a song’s production style) is worth holding as a hard line regardless of context. Practical takeaway for §3: no attempt to mimic Rick Astley’s specific voice/vocal delivery. If a vocal is used at all, keep it non-mimicking (different register, processed/robotic, wordless, or omitted entirely) — this is a “why bother” risk, not a “would definitely lose” risk, but there’s no upside to taking it on.
2. The line this project should hold
Evoke (all in the “not independently protectable” bucket per §1.1):
- The era and genre: late-80s Hi-NRG/dance-pop, Stock Aitken Waterman-style production.
- The tempo: ~113 BPM (the real song’s tempo — SongBPM, GetSongBPM; tempo numbers are metadata, not protected expression, so citing/matching them carries zero risk).
- The mood/key-color: a minor key in the same neighborhood as the original (commonly cited as B♭ minor — Tunebat — algorithmically-detected metadata, same reasoning as tempo above).
- The instrumentation/production texture: bright gated synth-pulse bassline (the real track’s “piano-bass” DX7 line), a punchy drum-machine 4-on-the-floor-adjacent groove, a Juno-106-style pad/chord stab, bright/sharpened reverb-heavy mix character. All documented, unprotected production choices — see Sound on Sound’s “Classic Tracks” writeup for the actual gear list (Yamaha DX7 for the bassline, Linn 9000 drums, Roland Juno-106, Lexicon/AMS reverb and delay on vocals).
Must stay original — not evoked, not approximated:
- The actual melody (verse and especially the hook line — the “never gonna give you up…” tune is the single most recognizable, and most protected, piece of the song).
- The actual lyrics, in whole or in any distinctive fragment.
- The actual chord progression as used in the song, note-for-note/degree-for-degree. Same key-neighborhood and mood is fine (see above); the exact progression is not — replicate the feeling of a minor-key SAW ballad-turned-dance-track, not its harmonic fingerprint.
- Rick Astley’s own vocal timbre/delivery (see §1.4) — if a vocal exists at all, it must not be a soundalike performance.
This is the same “evoke the vibe, not the fingerprint” principle the project’s own
assets/gen/music/season3_exploratory/README.md and AUDIO.txt already apply to every
other track (synthwave groove for neon_backalley.wav, tango for tango.wav, etc.) — none
of those claim to be a specific existing song, they evoke a genre. This is the same
move, just aimed at one very specific, very recognizable target because that’s the joke.
3. Compositional brief for tools/make_music.py
Everything below is achievable with the tooling already in the file (read in full for this
doc — render_voice(), pulse()/triangle(), kick()/hihat()/clap(), the
bar_len()/beats_to_samples() helpers, and the existing per-track make_*() pattern).
Nothing here needs a new synthesis primitive; it needs a new make_rickroll()-style
function following the existing recipe, plus one new melody/chord progression that does
not exist yet — the actual composing work, not just parameter tuning.
Tempo & meter: 113 BPM, 4/4 — matches the real song’s tempo exactly (safe, per §2).
Key: B♭ minor (or any adjacent minor key — A minor and B minor are both fine
substitutes if B♭ is awkward for note_freq()’s octave math) — matches the real song’s
mood/color, not its literal pitch center.
Structure: 8–16 bars, matching the project’s existing loop-length convention
(make_lidl_lunch/make_soccer are 8 bars at similar tempos). A short intro (2 bars,
bass + pad only, no lead) into a “chorus-shaped” main loop (lead melody enters) reads as
more song-like than a flat one-section loop — worth the extra bars for this one gag since
recognizability of structure (verse-into-hook shape) is part of what sells the reference,
and structure/form is exactly the kind of shape not covered by melody copyright ownership.
Bassline (the DX7 “piano-bass” reference): pulse() with a bright, fairly narrow duty
cycle (duty≈0.25–0.35 reads brighter/more synthetic than the 0.5 square used for e.g.
make_soccer’s bass) on a syncopated eighth-note pattern with rests — not a straight
walking bass. render_voice() already supports this via the same (notes, beats) event
list format every other track uses; short release (0.04–0.06) keeps it snappy/gated
rather than smooth, matching the “gated synth bass” character of the reference era.
Drums: reuse kick()/hihat()/clap() as-is (they’re already generic enough).
Pattern: kick on 1 and 3 (or a 4-on-the-floor-adjacent pattern per the “evoke the era”
brief — Hi-NRG/dance-pop of this period leans 4-on-the-floor more than a straight rock
backbeat), clap or hat on the offbeats. make_lidl_lunch’s existing kick/hihat step-loop
(step = beats_to_samples(0.5, bpm), kick every 4 steps, hat on odd steps) is close to the
right shape already and can be adapted directly.
Chords/pad: a Juno-106-style sustained pad — pulse() with a lower duty cycle
(≈0.2–0.25, same approach make_minigolf’s chord voice already uses) or triangle()
for a softer analog-pad feel, on whole/half-note chord changes under the bass and melody.
This progression must be composed fresh — pick a minor i–VI–III–VII or i–iv–v-type
progression (common, unprotectable minor-key pop shapes per §1.1) that is demonstrably
not the real song’s chord sequence. This is the one piece of actual composing work in
this brief, not a parameter to tune — whoever implements this needs to sit down and write
4–8 real chords, not port the real song’s changes with a different root note.
Melody/lead (the “hook” slot): also composed fresh — an original tune, same
register/contour ambition as a big synth-pop hook (mostly stepwise motion with a couple of
larger leaps, landing on strong beats), same rhythmic energy as the era (mostly eighth
notes with the occasional syncopated pickup), but a different actual sequence of pitches
and rhythm from “never gonna give you up, never gonna let you down.” render_voice() with
pulse() at a bright duty cycle (~0.4, matching make_lidl_lunch’s melody voice) is the
right timbre; the melody itself needs composing, not generating from a template.
Vocal: recommend no sung vocal at all — an instrumental synth-pop pastiche reads as
“unmistakably that meme” to a player primed by the visual gag (per the research questions’
own framing) without needing a voice in the mix at all, which sidesteps §1.4 entirely.
If a vocal element is wanted for extra punch, keep it non-mimicking: a wordless “ooh/aah”
pad-style vocal-synth patch (not a performance of Astley’s actual delivery/register/rasp),
or lean on the existing bytebeat-adjacent 8-bit idiom for a “sung” line instead of a real
voice.
What doesn’t need new tooling: waveform types, envelope shaping, percussion
one-shots, the mix/finalize pipeline, the WAV export — all already exist and already
produce exactly this instrumentation family (make_lidl_lunch and make_soccer are both
already “bright pulse bass + drum-machine percussion + pulse-wave lead” in a similar BPM
range). This is squarely inside what the tool already does; it just hasn’t been pointed at
this specific era/mood/melody combination yet.
4. Risk assessment and recommendation
Residual risk, even done exactly as specified above: low, but not literally zero. Two real (if narrow) exposure paths remain even with a fully original melody/chords:
- “Selection and arrangement” arguments (§1.1, Gray v. Perry-style) — a claim that
copying enough generic elements in combination (tempo + key-color + instrumentation
- structure + genre, all at once, all deliberately aimed at one specific song) adds up to something a court could call substantially similar even without melody overlap. This is a real doctrine, but it’s also the losing side more often than not in reported cases, and courts have been especially skeptical of it for “genre pastiche” facts specifically (see the ABA piece in §1.1, written about exactly this AI-generated-pastiche fact pattern).
- Someone unfamiliar with music-copyright nuance (a platform’s automated content-ID system, a casual viewer, a future collaborator) assuming “sounds like Rick Astley” == “is infringing” — a perception/moderation risk, not really a legal one, but worth naming since it could cause friction (e.g. an automated takedown on a video-sharing platform if this ever gets streamed/clipped) even where the legal analysis is sound.
Comparison to the current bytebeat fallback: the bytebeat approach (already built,
already shipped per NEXT.md) carries essentially zero residual risk of either kind —
it doesn’t share tempo, key, instrumentation, or structure with the real song, so there’s
no “selection and arrangement” argument available even in principle, and nothing an
automated system would ever flag. That’s not an accident; it was the explicit design goal
(assets/gen/music/season3_exploratory/README.md: “deliberately weird/broken-sounding is
the joke”).
Recommendation: build the pastiche described in §3 as an addition alongside the bytebeat, not a replacement — reachable as an “upgraded” or alternate rickroll sting rather than a like-for-like swap. This is a genuine two-tier joke (the wrong-but-familiar bytebeat vs. the parody-that-almost-lands-if-you-squint synth-pop track) and it lets the project keep the zero-risk fallback as the default while adding a strictly-more-legally- exposed-but-still-well-inside-the-safe-zone option for the moment the trove actually pays off. Given the residual risk above is genuinely low and well-precedented (§1.1–1.3), and the upside (a payoff that actually reads as “the rickroll” rather than “spooky bit-crushed noise”) is real, this is worth the small amount of extra exposure — do build it, but compose the melody/chords fresh per §3 rather than taking any shortcut that ports the real song’s actual notes with the serial numbers filed off, and skip the sung vocal per §1.4. If Torsten wants a belt-and-suspenders version, the German UrhG §51a pastiche exception (§1.3) is a strong, on-point, and rarely-litigated-against fallback specifically because its legislative history calls out meme/internet-culture pastiche by name — but the composition brief in §3 is designed so that fallback should never actually need invoking.
5. Post-build verification against the real chorus (2026-08-08)
make_rickroll() (tools/make_music.py) was built from this doc’s §3 brief before this
section existed — the melody/chords were composed fresh from the brief’s constraints,
without seeing the real song’s actual transcription. Once Torsten sourced Hooktheory’s
chorus data directly (theorytab page itself 403’d to automated fetching; pasted in
manually), it became possible to check the finished composition against the real thing
note-for-note rather than just trusting the brief avoided it by construction.
Real chorus, as transcribed: Db major, 114 BPM, melody range Ab3–Ab4, most-used chord ii — matching the tempo/key claims already gathered from other sources in §1, and resolving what looked like a source disagreement: a chord-recognition source’s “Ab major, chords Ab-Fm7-Bbm7-Ebm7” turns out to be the same four absolute chords as Hooktheory’s Db-major reading (ii=Ebm9, V=Ab(add9), iii=Fm7, vi=Bbm7) — just labeled from a different tonal center, not a real disagreement. The chorus hook’s actual melody is a syncopated, irregular rhythm (dotted-eighths, sixteenth-note pickup runs, mid-phrase rests) over a stepwise-descent-plus-ascending-pickup contour: F4-F4-Eb4 / Ab3-Bb3-C4-Bb3 / Eb4-Eb4-Db4 / C4-Bb3 / Ab3-Bb3-Db4-Bb3, repeating with variation across 8 bars.
Comparison to make_rickroll()’s hook (Bb minor: Bb4-Db5-F5-Eb5-Db5 |
Gb4-Bb4-Db5-Bb4-Ab4 | F4-Ab4-Db5-Ab4-F4 | Eb4-C5-Eb5-C5-Bb4, an identical
“4 eighths + quarter + rest” shape in every bar):
- Rhythm: no overlap — the real hook’s irregular, syncopated shape shares nothing with the composed hook’s flat, uniform bar-shape.
- Melody/contour: no overlap — different scale-degree sequences, different interval shapes; the real hook is a stepwise descent punctuated by ascending pickups, the composed one leans on repeated-note-then-leap figures.
- Chords: two of the composed progression’s four triads (Bbm, Ab) also appear among the real song’s four chords, but as plain triads (not the real 7th/9th voicings), in a different order with no shared consecutive motion (composed: i→VI→III→VII = Bbm→Gb→Db→Ab; real: ii→V→iii→vi = Ebm→Ab→Fm→Bbm). Per §1.1, individual chords aren’t protectable — only the sequence as used is, and that sequence differs here.
Conclusion: verified clean. The one thing this doc’s brief had to take on faith (that composing from constraints, without copying, wouldn’t accidentally converge on the real melody) held up against an actual side-by-side check, not just a design argument.
6. Decision reversal: literal reproduction instead of original composition (2026-08-08)
Torsten’s explicit decision, after being shown the risk tradeoff directly (asked
whether to (a) stay original and just tune closer to the real tempo/key/voicing, or
(b) rewrite make_rickroll() to literally use the transcribed melody+chords below — the
exact thing §2/§5 above concluded should be avoided): (b), use the real notes. Stated
rationale: relying on meme-culture/parody norms for the legal footing here, not on
avoidance-by-composition. This supersedes §2’s “must stay original” line and §5’s
“verified clean” conclusion for this specific track — both remain accurate accounts of
what was true before this decision, not retracted, just superseded going forward. The
zero-risk bytebeat rickroll track (§4’s fallback) is unaffected and still plays as one
of the two random picks at trigger time.
Source data (Hooktheory theorytab for “Never Gonna Give You Up”, chorus section,
pasted in manually since the page itself 403’s automated fetching — notes/chords/keys
only; the clipboard payload’s redundant tick-level duplicate of the same data is omitted
here for length):
{"notes":[{"beat":1,"duration":0.75,"sd":"3","octave":0},{"beat":1.75,"duration":0.75,"sd":"3","octave":0},{"beat":2.5,"duration":1,"sd":"2","octave":0},{"beat":4,"duration":0.25,"sd":"5","octave":-1},{"beat":4.25,"duration":0.25,"sd":"6","octave":-1},{"beat":4.5,"duration":0.25,"sd":"7","octave":-1},{"beat":4.75,"duration":0.25,"sd":"6","octave":-1},{"beat":5,"duration":0.75,"sd":"2","octave":0},{"beat":5.75,"duration":0.75,"sd":"2","octave":0},{"beat":6.5,"duration":0.5,"sd":"1","octave":0},{"beat":7,"duration":0.25,"sd":"7","octave":-1},{"beat":7.25,"duration":0.75,"sd":"6","octave":-1},{"beat":8,"duration":0.25,"sd":"5","octave":-1},{"beat":8.25,"duration":0.25,"sd":"6","octave":-1},{"beat":8.5,"duration":0.25,"sd":"1","octave":0},{"beat":8.75,"duration":0.25,"sd":"6","octave":-1},{"beat":9,"duration":0.75,"sd":"1","octave":0},{"beat":9.75,"duration":0.75,"sd":"2","octave":0},{"beat":10.5,"duration":0.25,"sd":"7","octave":-1},{"beat":10.75,"duration":0.75,"sd":"6","octave":-1},{"beat":11.5,"duration":0.5,"sd":"5","octave":-1},{"beat":12.5,"duration":0.5,"sd":"5","octave":-1},{"beat":13,"duration":1,"sd":"2","octave":0},{"beat":14,"duration":1,"sd":"1","octave":0},{"beat":16,"duration":0.25,"sd":"5","octave":-1},{"beat":16.25,"duration":0.25,"sd":"6","octave":-1},{"beat":16.5,"duration":0.25,"sd":"1","octave":0},{"beat":16.75,"duration":0.25,"sd":"6","octave":-1},{"beat":17,"duration":0.75,"sd":"3","octave":0},{"beat":17.75,"duration":0.75,"sd":"3","octave":0},{"beat":18.5,"duration":1.25,"sd":"2","octave":0},{"beat":20,"duration":0.25,"sd":"5","octave":-1},{"beat":20.25,"duration":0.25,"sd":"6","octave":-1},{"beat":20.5,"duration":0.25,"sd":"7","octave":-1},{"beat":20.75,"duration":0.25,"sd":"6","octave":-1},{"beat":21,"duration":0.75,"sd":"5","octave":0},{"beat":21.75,"duration":0.5,"sd":"7","octave":-1},{"beat":22.25,"duration":0.5,"sd":"1","octave":0},{"beat":22.75,"duration":0.25,"sd":"7","octave":-1},{"beat":23,"duration":1,"sd":"6","octave":-1},{"beat":24,"duration":0.25,"sd":"5","octave":-1},{"beat":24.25,"duration":0.25,"sd":"6","octave":-1},{"beat":24.5,"duration":0.25,"sd":"7","octave":-1},{"beat":24.75,"duration":0.25,"sd":"6","octave":-1},{"beat":25,"duration":0.75,"sd":"1","octave":0},{"beat":25.75,"duration":0.75,"sd":"2","octave":0},{"beat":26.5,"duration":0.75,"sd":"7","octave":-1},{"beat":27.25,"duration":0.25,"sd":"6","octave":-1},{"beat":27.5,"duration":0.5,"sd":"5","octave":-1},{"beat":28.5,"duration":0.5,"sd":"5","octave":-1},{"beat":29,"duration":0.5,"sd":"2","octave":0},{"beat":29.5,"duration":0.5,"sd":"1","octave":0},{"beat":30,"duration":1,"sd":"1","octave":0}],
"chords":[{"beat":1,"duration":1.5,"root":2,"type":9},{"beat":2.5,"duration":2.5,"root":5,"type":5,"adds":[9]},{"beat":5,"duration":1.5,"root":3,"type":7},{"beat":6.5,"duration":2.5,"root":6,"type":7},{"beat":9,"duration":1.5,"root":2,"type":9},{"beat":10.5,"duration":2.5,"root":5,"type":5,"adds":[9]},{"beat":13,"duration":1.5,"root":3,"type":7},{"beat":14.5,"duration":2.5,"root":6,"type":7},{"beat":17,"duration":1.5,"root":2,"type":9},{"beat":18.5,"duration":2.5,"root":5,"type":5,"adds":[9]},{"beat":21,"duration":1.5,"root":3,"type":7},{"beat":22.5,"duration":2.5,"root":6,"type":7},{"beat":25,"duration":1.5,"root":2,"type":9},{"beat":26.5,"duration":2.5,"root":5,"type":5,"adds":[9]},{"beat":29,"duration":2,"root":3,"type":7},{"beat":31,"duration":1,"root":4,"type":5,"inversion":1,"adds":[9]},{"beat":32,"duration":0.75,"root":5,"type":11},{"beat":32.75,"duration":0.25,"root":4,"type":5,"inversion":2,"adds":[9]}],
"keys":[{"beat":1,"scale":"major","tonic":"Db"}],
"songStats":{"key":"Db Major","tempo":114,"meter":"4/4","genre":["Blues","Pop","House"],"melodyRange":"Ab3-Ab4","mood":["Smooth","Complex","Unexpected","Bright"],"mostUsedChord":"ii"}}
Decoded to actual note names/octaves (Db major; scale degree → semitones-from-tonic
via the standard major-scale pattern 0,2,4,5,7,9,11; octave tag −1/0 is a full octave,
converted to this project’s scientific-pitch-notation octave numbers the same way
note_freq() already reads them):
degree,octave → note: 1,0→Db4 · 2,0→Eb4 · 3,0→F4 · 5,0→Ab4 · 5,-1→Ab3 ·
6,-1→Bb3 · 7,-1→C4 — these are the only seven combinations that actually occur in the
data above.
Melody, gaps filled in as explicit rests (8 bars, 4/4, 114 BPM):
Bar1: F4·.75 F4·.75 Eb4·1 R·.5 | Ab3·.25 Bb3·.25 C4·.25 Bb3·.25
Bar2: Eb4·.75 Eb4·.75 Db4·.5 C4·.25 Bb3·.75
Bar3: Ab3·.25 Bb3·.25 Db4·.25 Bb3·.25 Db4·.75 Eb4·.75 C4·.25 Bb3·.75
Bar4: Ab3·.5 R·.5 Ab3·.5 Eb4·1 Db4·1 R·1
Bar5: Ab3·.25 Bb3·.25 Db4·.25 Bb3·.25 F4·.75 F4·.75 Eb4·1.25 R·.25
Bar6: Ab3·.25 Bb3·.25 C4·.25 Bb3·.25 Ab4·.75 C4·.5 Db4·.5 C4·.25 Bb3·1
Bar7: Ab3·.25 Bb3·.25 C4·.25 Bb3·.25 Db4·.75 Eb4·.75 C4·.75 Bb3·.25 Ab3·.5
Bar8: R·.5 Ab3·.5 Eb4·.5 Db4·.5 Db4·1 R·2
Chords — the ii9–V(add9)–iii7–vi7 loop, 3 full statements plus a 4th ii9–V(add9) before the turnaround bar:
[ii9=Ebm(add9)·1.5, V(add9)=Ab(add9)·2.5, iii7=Fm7·1.5, vi7=Bbm7·2.5] × 3
+ [ii9·1.5, V(add9)·2.5] (bar 7, 4th ii-V)
+ [iii7=Fm7·2, IV(add9)inv1=Gb(add9)·1, V11≈Ab(add9/11)·0.75, IV(add9)inv2·0.25] (bar 8, turnaround)
make_rickroll() in tools/make_music.py is built directly from this decoded table —
voicings are simplified triad/7th/9th chords rather than exact inversions (the engine’s
render_voice() doesn’t model bass-note inversions), and bass notes are the chord roots
sustained per-segment rather than a note-for-note reproduction of the real bassline (not
transcribed here). Rendered to assets/gen/music/rickroll_hooktheory.wav, wired as the
'rickroll_hooktheory' MusicName.
Sources
- ABA Entertainment & Sports Lawyer — “Never Going Out of Style” (AI pastiche, genre non-copyrightability), 2025
- NYU JIPEL — Un-Blurring Substantial Similarity
- BitLaw — Works Unprotected by Copyright Law
- Lexology — Gray v. Perry, selection-and-arrangement of unprotectable elements
- ClickNClear — Sound-alikes, all you need to know (17 U.S.C. §114(b))
- Wikipedia — Sound-alike
- Wood Law Offices — Music Copyright: Covers, Samples & Infringement
- Oyez — Campbell v. Acuff-Rose Music, Inc. (1994)
- Justia — Campbell v. Acuff-Rose Music, Inc., 510 U.S. 569 (1994), full opinion
- Kluwer Copyright Blog — The dawn of pastiche (German UrhG §51a)
- copyrightexceptions.eu — UrhG §51a statute text
- legislation.gov.uk — CDPA 1988, Section 30A (parody/pastiche fair dealing)
- Fieldfisher — The development of the parody exception to copyright infringement
- vLex — Midler v. Ford Motor Co., 849 F.2d 460 (9th Cir. 1988)
- Justia — Waits v. Frito-Lay, Inc., 978 F.2d 1093 (9th Cir. 1992)
- Sound on Sound — Classic Tracks: Rick Astley “Never Gonna Give You Up” (production/gear detail)
- Wikipedia — Never Gonna Give You Up (genre, credits, production)
- SongBPM — Never Gonna Give You Up tempo
- GetSongBPM — Never Gonna Give You Up tempo
- Tunebat — Never Gonna Give You Up key/BPM data