Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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/)

SourceWhat it actually isUse
pixelsp33d.jpg (150×150)Orange blob/taco with eyes + “PIXEL” wordmark. YT/Discord avatarHUD score badge (as specced)
pixel/robot3bild, pixel/unknownVoxel/Minecraft-style robot: white body, cyan eyes, copper antenna, control panel, copper feetPlayer sprite base (proposed)
pixel/kleki_2021_06_03Hand-drawn brown mouse/bear headInterlude art / secret
kn00t/gdrive/*.png8 native 16×16 pixel-art portraits: Cyber, Diving, EvilOriginal, God, InBlack, OpenHair, RedShirt, LazyPer-level boss forms — zero conversion needed
kn00t/kn00t_1..3.webp3 more kn00t busts (straw hat / top hat / eyepatch), ~16×16 upscaledExtra boss forms, dialogue portraits
kn00t/kn00t_67.gif2-frame kn00t idle loop, straw hat + rainbow shirtThe “6-7” special cameo (spec req.)
kn00t/nifty_1kn00t full-body wizard, yellow hat, rainbow shirtBoss full-body sprite reference
kn00t/nifty_2Red mushroom house with rainbow-lit doorLevel scenery / hub
kn00t/nifty_3Yellow horned creature, blue eyesCollectible companion / powerup
kn00t/nifty_4Red “piXELSP33D” wordmarkTitle screen logo
bg/signal-*.jpg ×5Real photos by Moritz: puffy blue sky · mackerel sky w/ tree · field w/ power pylons · 2× above-the-clouds from a plane wingParallax backgrounds, one set per level
video/VID-20210802-WA0000.mp4 (3.2s)Hand-drawn stick-figure flipbook on lined paperPrequel 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)

ItemRoleEffect
PizzaFood+10 energy
KebapFood+25 energy
vegan SchnitzelFood (rare, Moritz’s own)+40 energy, full heal
Lidl River ColaSpeed pickup+1 speed tier — the levelling currency
FootballContext-dependentObstacle 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.

ItemRoleEffect
Drop ChickenHarmless, patrols like a Keen snail+8 energy on contact — safe, even edible
Poison EggObstacle, patrols like a Keen snail−35 energy on contact (harder-hitting than Football — “possibly fatal”)
uervel knightPowerupOnce 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 live items/hazards/solids/camera/boss/levelTime plus player position, then swaps to the Underworld LevelData (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/bossSprite mechanism 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.destination on the source level’s LevelData), 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:

  1. Now (Season 1): just food.
  2. Later: a chest — a rarer, higher-value one-off pickup, not a currency. No new Player field or HUD readout needed; it’s the same ItemDef/kind: 'food'-style pattern every pickup already uses, just worth more and visually distinct (a treasure-chest sprite instead of a food item).
  3. 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.

#SceneVerbBackgroundBoss form
0Prequellined paper
1MinigolfJumpmackerel sky + treeLazykn00t
2Lidl LunchWalk / Runpuffy blue skykn00tInBlack
3Soccer w/ friendsRun (sprint)field + power pylonskn00tRedShirt
4TanzstundeSneak (rhythm)mackerel sky, dusk gradekn00tOpenHair
5Above the CloudsFly / Jetplane wing ×2Cyberkn00t → EvilOriginalkn00t
Post-gameallGodkn00t

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 in assets/gen/. Never hand-edit gen/.
  • 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.game record 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

PhaseOutputRough size
P0 Asset pipelinetools/pixelize.sh, palette, assets/gen/ populated, contact sheets to eyeballsmall
P1 Player + kn00t sprites10 Pixel sprites, boss sprite sheets from the 16×16 portraitsmedium
P2 Engine corecanvas loop, fixed timestep, input, integer camera, 4-layer parallax, collisionmedium
P3 Gameplayenergy/speed model, items, obstacles, the 5 movement verbs, boss encounters, egg-dish enemy + uervel knight — done, prototyped in Lidl Lunchlarge
P4 Levels 1–5level data files, per-level backgrounds, tuning — done, see belowlarge
P5 Narrativeprequel/interlude/sequel cutscene player, caption system, narration slotsmedium
P6 atproto + scoregame record, run/trophy lexicons, score screen with PIXEL badgemedium
P7 Shipstatic dist/ build + mobile touch controls; deployment to pixelsp33d.de left to yousmall

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.

#LevelVerbNew engine work it needed
0MinigolfJumpreveal/heal pit hazards + a level timer (see below); gaps tuned to 30px after testing showed 40px+ was too tight for the achievable jump range
1Lidl LunchWalk/Runthe 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
2Soccer w/ friendsRun/sprintfootballGoal 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
3TanzstundeSneak/rhythmwaltz 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
4Above the CloudsFly/Jetsustained-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 + height constant) across the toggle, in Player.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_Y clamp, same pattern as the existing FALL_OUT_Y floor 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 moveAndCollide correctly 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_Y in lidl_lunch.ts) instead of directly overhead. Verified via debug instrumentation: jumping from crate3 lands exactly on the stash’s surface, both items collect correctly.
  • 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 in update(). While not running, every uncollected/unkicked soccerBall’s current rect is added to a per-frame copy of solids (moveSolids) right before player.update(), so moveAndCollide treats 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 of moveSolids entirely, 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 — kickVy accumulates KICK_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 (kickVy always 0), and that made scoring trivial: with the ball always at exactly footballGoal’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.
  • 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 to footballGoal directly — collectFood, goal SFX, the “GOAL! +40” fanfare — just reached by a well-aimed pass instead of on foot. Missing — the ball exits the level’s width (±50 margin), or falls back to kickBaseY without 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 as make_tiles.sh’s procedural ground textures), a low rock-ceiling platform over a corridor that a patrolling waterfall obstacle (new ItemDef, tall like discoBall) 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. A chest (new ItemDef, the reward-progression’s tier-2 pickup) sits past the corridor; an exit (exit_ladder sprite, repurposing the level’s own bossX/bossSprite/bossName fields 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 — tsc and the smoke test both stayed clean throughout): the exit restored the player’s raw saved y, which was captured while crouching (crouching is how the portal triggers in the first place). On the very next tick, sneaking recomputes to false (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 before moveAndCollide’s horizontal pass even runs — which then reads the ground itself as a sidewall and snaps x to its left edge (0). Fixed by saving/restoring the player’s feet position (y + height, invariant to crouch state) instead of raw y, and explicitly forcing sneaking = false before deriving the restored y from it.
  • Exit trigger reuses Boss, not a new mechanism: the Underworld level’s own bossX/bossSprite are checked via the exact same boss.checkReached() every level already uses for its real boss encounter; update() just branches on whether underworldReturn is 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 called playMusic() at all, so the “return to normal” case only worked because nothing had actually changed. Fixed by having enterUnderworld() explicitly stopMusic() (silence fits a risky cave, and no track exists for it yet) and exitUnderworld() 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 (a console.log isn’t a console.error, so it’s invisible to tools/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.
  • 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, closing then opening, around the moment the real level swap happens: trigger the portal → closing plays out against the still-loaded old scene → at t=1 the real enterUnderworld/exitUnderworld call fires (unchanged) → opening plays out against the now-loaded new scene → 'playing' resumes. The player’s rendered frame is forced to fall (entering) or jump_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 with globalCompositeOperation = 'destination-out' - this rendered solid black at every radius, hole included. Canvas2D has no actual layers: the fillRect had 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 whatever render() 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.

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: requiresSneak omitted on PortalDef, 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 technique tools/pixelize_bg.sh already 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’s cave_floor ground tile (the floor doesn’t need to be lava-specific, just cave-like). The hazard is lava (new ItemDef), 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: at w:105, it’s wider than the ~93px of horizontal travel a max-speed-tier running jump covers over its full airtime (JUMP_VELOCITY/GRAVITY in src/game/player.ts), so no jump clears it regardless of speed tier — a distinct mechanism from River Cola’s, not a rerun of it. A chest (the same ItemDef River Cola already uses, no new reward item needed) sits past the pool; the exit reuses the same bossX/bossSprite-as-exit-trigger pattern.
  • Real balance bug found and fixed (only a driven test with actual frame-by-frame logging caught it — tsc and 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 was w:140, generously past the jump-distance minimum. But takeHit’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 to w: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 the ItemDef in src/game/items.ts since 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 calling enterUnderworld() again — looked like the game was stuck respawning inside the Underworld forever, level.id never 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 forced sneaking = false before restoring position, so requiresSneak: 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) in exitUnderworld(): 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 (lava and waterfall): drawItems() applies a slow ctx.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 the used gate below existed.
  • PortalDef.used, so the chest can’t be farmed: loadLevelData() rebuilds a fresh, uncollected copy of a destination’s items on 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 adding used?: boolean to PortalDef, set true the moment a portal is first entered and checked alongside the existing overlap/requiresSneak conditions — 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 from solids. 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 ground HAZARD_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 in update()) plus HazardDef on LevelData. solids had to change from a direct reference to level.platforms into 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 reset levelTime, 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: any player.justRespawned now resets levelTime too, not just hazards - a respawn is a fresh attempt in every sense, clock included.

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) fits CROUCH_H (14) with margin but not STAND_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 to FALL_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 tsc or the smoke test — only an actual screenshot surfaced them):
    • Items placed below groundY were 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 new ItemInstance.hiddenUntilHazard field (an index into level.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.
  • 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 in main.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 in helpers.ts, alongside the existing bob() (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() in main.ts): a handful of dancePartner instances pass sharp=true to the waltz() helper, swapping the smooth Math.sin pulse 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 plain onGround() 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: vy correctly 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): seven altitudePatrol() zones spread across the level’s full vertical range (y from ~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 in clouds.ts (parallel to the shared patrol(), 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 in drawHud) — 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 for speedTier.
  • collect_dropchicken SFX: Drop Chicken (the harmless half of the egg-dish family — “no danger, eatable”, per STORY.txt) was silently reusing the generic collect blip, 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 explicit preload() list, not just generated into assets/gen/hud/assets.ts only 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.pytools/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 SFX AudioContext on 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), so playMusic() records what should be playing (desiredMusic) even when it can’t actually start yet, and unlockAudio() 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 (M key): 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() in audio.ts silence the music GainNode in 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 moment M was pressed. A small “music muted (M)” HUD line (main.ts’s render()) confirms the state - checked directly via a driven test (audio.ts’s isMusicMuted(), 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, in startTransition(), right as the vignette begins closing - not inside enterUnderworld/exitUnderworld themselves, 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 than collect_powerup’s chime - previously chests silently reused collect_powerup outright, giving the knight pickup and a treasure trove find the exact same sound despite being unrelated moments.
  • kick: a snappier, more percussive impact than land’s soft thud - previously the kick trigger reused land directly (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.txt are signed and will expire — assets are already pulled into assets/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 · devhttp://pad.local:5175/, the existing Vite dev server + TV test-redirect flow (§4/NEXT.md, unchanged).
  • pxl · playtesthttp://192.168.178.42:8095/, a static build on trullala (the homelab test-offload box, see §“Fleet offload” in NEXT.md), served by a pixelsp33d-demo.service systemd unit (python3 -m http.server 8095). tools/deploy_trullala.sh builds dist/ and rsyncs it there. LAN-only, laptop- independent — survives the dev server not running. Bookmarked by raw IP rather than trullala.local on purpose: easier to edit just the last octet on a TV remote than retype a hostname (see memory/reference_tv_testing.md).
  • pxl · livehttps://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.depixelsp33d.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.shhttps://docs.pixelsp33d.de/ (same Pages account/pattern as the game). Every build is version-stamped via git describe --tags --always (matching the existing YYYY.MM.DD annotated-tag convention), shown in the sidebar header and browser tab title on every page (MDBOOK_BOOK__TITLE, mdbook’s own env-var config override — no book.toml edit 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.shhttps://preview.pixelsp33d.de/, a separate Pages project (pixelsp33d-docs-preview) gated behind a Cloudflare Access application (Zero Trust team holy-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.md audits) stay deliberately out of the book.
  • Real Cloudflare gotcha, unresolved: creating the Access application via API (POST /accounts/{id}/access/apps) returned 1010: auth.forbidden through 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 collected after 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 ambush collected on 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 optional resetSpeedTier flag, 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 as justJumped/justRespawned — reset at the top of update(), set alongside the existing FALL_OUT_Y respawn branch) drives a new fall_out SFX from main.ts — a fast descending whistle ending in an impact crash, distinct from time_out’s slower, resigned 4-note descending sequence (added to timeOut() directly, since that function already lives in main.ts). Two different failure feelings, not the same sound reused twice.
  • Underworld reverb: a ConvolverNode fed a synthetic impulse response (decaying noise generated directly into an AudioBuffer, not a recorded sample — matching this project’s “generate everything” rule), built lazily in audio.ts the first time it’s needed. A new setReverbEnabled(bool) toggle (not a playSfx parameter — call sites shouldn’t need to know which level they’re in) is called from loadLevelData() based on level.id.startsWith('underworld_'). Bug caught while wiring this up: exitUnderworld() restores level state manually rather than going through loadLevelData() (already true of playMusic before 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): false on a surface level, true immediately after entering either Underworld room, false again after exiting back out.
  • Underworld patrols: a new bat obstacle (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:140 to w:105 for exactly this failure mode) occasionally still borderline at the reduced width, not eliminated. Flagging for whoever next touches that balance, not fixed here.

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 existing FALL_OUT_Y safety net and respawns exactly like falling off the world anywhere else in the game — confirmed via a driven test (forced fall, watched y increase past 276 and land back at spawnX/spawnY). The “water” is purely a visual read on top of that, not real swim physics.
  • Water tile (water.png, also new in make_tiles.sh): a blue checkerboard, drawn as a Decoration filling each gap’s visual area at y: 200, h: 16 — within the same ~24px below groundY this engine has room for without a vertical camera (the same constraint the Minigolf hidden gallery ran into). Decoration gained a new shimmer?: boolean field; drawDecorations() applies the same ctx.filter = hue-rotate(...) cycle already built for lava/waterfall in drawItems() 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.

ConstantFileSeason 1 valueControlsTurning it up/down
HAZARD_REVEAL_DISTANCEmain.ts130pxHow close a Minigolf pit lets you get before it opensSmaller = less reaction time (see Minigolf draft above)
HAZARD_HEAL_DELAYmain.ts2.0sHow long a healable pit stays open before resealingShorter = less time to walk across instead of jumping
HURT_DURATIONplayer.ts0.35sStun window after a hit (movement input locked)Longer = a hit costs more control time, not just energy
HURT_KNOCKBACKplayer.ts90px/sSpeed you’re flung away from whatever hit youLower = “stickier” hits, easier to get hit again right after
INVULN_DURATIONplayer.ts1.0sPost-hit grace period where further hits no-opShorter = harsher, punishes lingering in a hazard
GRAVITY / JUMP_VELOCITYplayer.ts900 / -300Fall acceleration / jump strengthChanges jump arc height & hang time across every jump-based level
RUN_BASE / SPEED_TIER_BONUSplayer.ts80 / +12 per colaRun speed baseline and per-speed-tier bonusAffects how forgiving a gap is at a given speed tier (§4 gap tuning)
SNEAK_SPEEDplayer.ts28px/sCrouch-walk speedSlower = crouching through Tanzstunde’s rhythm costs more against its timer
FLOAT_FALL_CAPplayer.ts60px/sUn-thrusted fall speed in a fly-enabled levelHigher = closer to the old ceiling-rush exploit (§4); this is the knob that fix relies on
level.timeLimitper-level fileMinigolf 30s, Tanzstunde 40sLevel 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-hazard phase offset (so multiple hazards in one level don’t flip in lockstep) — a square wave, not Math.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 in drawPlayer’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.

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.hasX boolean 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 same Input methods 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 vx while 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 warnDuration draft (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?) reads opts.knockback ?? HURT_KNOCKBACK (and similarly for the other two), and the item-collision loop in main.ts passes def.knockback / def.hurtDuration / def.invulnDuration (all optional on ItemDef) at the call site instead of nothing.
  • Distinct from a “sticky hazard” (lava/quicksand-style continuous tick damage that ignores invulnTimer altogether) — 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 via localStorage) the first time the game is actually finished; and a standalone ?demo=backdrops mode 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 later enterUnderworld() 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.ts gained a testRedirect() 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 — and npm run dev:playtest (HMR off) alongside plain npm run dev (HMR on), so a live playtest session doesn’t get reset mid-run every time a file saves.