diff --git a/HANDOFF.md b/HANDOFF.md index 4fdf297..300897e 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,281 +1,220 @@ -# HANDOFF — Splatter Tennis (for the next Claude session) +# HANDOFF — Splatter Tennis -Read this first, then `~/.claude/projects/.../memory/splatter-tennis-history.md` (auto-loaded) -for the long history. This file is the precise mid-task state as of 2026-07-11. +**This file is the source of truth.** `PLAN.md` is historical (the original build plan, all +shipped); `POLISH-PLAN.md` is deleted — everything it specified is done and recorded below. +Also auto-loaded: `~/.claude/projects/.../memory/splatter-tennis-history.md` for the long history. + +Last updated 2026-07-27 after Oliver's 35-game retest. **Open issues are at the bottom — start there.** ## What this is -SNES-style pseudo-3D tennis (Super Tennis reference) that transitions into a zombie splatter -game while keeping all tennis aesthetics (umpire voice, 15/30/40 scoring, ball boys, applause). -Zero-dependency vanilla JS, native ES modules, no build step. Mobile portrait one-handed play -is the primary target (tap = swing/serve, flick = aimed shot, auto-run assist does positioning). +SNES-style pseudo-3D tennis (Super Tennis reference) that transitions into a zombie splatter game +while keeping every tennis aesthetic (umpire voice, 15/30/40 scoring, ball boys, applause). +Zero-dependency vanilla JS, native ES modules, no build step. Mobile portrait one-handed play is +the primary target. -- **Run locally**: `preview_start` name `splatter-tennis` (python http.server :8641, .claude/launch.json) -- **Deploy**: push to `git@gitea.wuwi.one:wuwi/splatter-tennis.git` (main) → Coolify webhook - auto-builds the Dockerfile (nginx, everything served no-cache). Oliver tests on his phone at - splatter-tennis.wuwi.one within ~30s of a push. -- **Oliver edits files himself** (tuning values, sometimes sprites/sheets). ALWAYS Read current - file state before editing; never assume your last version is what's on disk. +- **Run locally**: `preview_start` name `splatter-tennis` (python http.server :8641, `.claude/launch.json`) +- **Deploy**: push to `git@gitea.wuwi.one:wuwi/splatter-tennis.git` (main) → Coolify webhook builds + the Dockerfile (nginx, no-cache). Oliver tests on his phone at splatter-tennis.wuwi.one ~30s after + a push. **Bump `main.js?v=N` in index.html every time** or phones keep the old build. + The Dockerfile must also COPY any new top-level file (it currently ships `index.html`, + `manifest.webmanifest`, `*.js`, `assets/`). +- **Oliver edits files himself** (tuning values, sometimes sprites). ALWAYS Read current file state + before editing; never assume your last version is what is on disk. ## Module map (all in repo root) -`config.js` TUNE (live panel, T key) + constants · `state.js` G object + P/O/ball/score + setupPoint -· `gameplay.js` ballistics/swings/serve/rules/AI/update + transition + splatPoint · `zombies.js` -zombie/gore/particle state + updateZombies · `render.js` camera/palette-lerp/sprites/drawing -· `input.js` keys/touch/flick · `audio.js` sfx synth + recorded umpire (assets/audio/*.m4a, -made with macOS `say -v Daniel`) · `panel.js` · `main.js` boot, loop, umpire-tap cheat, window.T. +`config.js` TUNE (live T panel) + structural constants + `UI` palette · `state.js` the `G` object + +P/O/ball/score + `setupPoint`/`sendBoy` · `gameplay.js` ballistics/swings/serve/rules/AI/`update()` + +transition + `splatPoint` + ball-boy routines · `zombies.js` zombie/gore/particle state + +`updateZombies` · `render.js` camera/palette-lerp/sprites/all drawing · `input.js` keys/touch/flick · +`audio.js` sfx + recorded umpire (`assets/audio/*`, macOS `say -v Daniel`) · `panel.js` the T panel · +`main.js` boot, loop, umpire-tap cheat, `window.T` test surface. Two runtime-only import cycles (documented in headers): input↔gameplay, zombies↔gameplay. -## Zombie mode — how it works today -- Trigger: win your first game in tennis mode, OR **triple-tap the umpire** (x=7.1, netZ) within - 1.6s (cheat; listener in main.js → `forceZombies()` in gameplay.js). -- Transition (`G.mode='transition'`, timeline in gameplay.update via `G.transT`): applause cut, - palette lerp (PAL in render.js), umpire "Quiet… please" at low playbackRate (voice() has a - `rate` param), CPU opponent swapped for zombie-CPU (variant 'c'), scripted first spawns, - then `G.mode='splatter'`. -- Splatter: you serve at zombies; every ball carries `ball.pow` (= shot quality); hit with - pow >= TUNE.zSplatQ → splatter (gore decal + particles + splat() sound + splatPoint('P')), - else stagger/pushback and the ball is spent (`lastHit='X'`). Zombie crossing your baseline → - splatPoint('O'). Tennis scoring machinery keeps running (deuce, advantage, "Game, player"). - Ball boys mop gore they pass. HUD shows THEM instead of CPU. -- Balance knobs in TUNE (T panel): zSpeed, zSpawnEvery, zMax, zSplatQ, zPushback. +## How the game flows +Tennis → win a game → **transition** (`G.mode='transition'`, timeline on `G.transT`) → **splatter**. +In splatter you serve at zombies; `ball.pow` ≥ `TUNE.zSplatQ` splatters, below that it staggers. +Tennis scoring keeps running. `TUNE.killsPerPoint` kills = one point; games/sets accumulate; +6 games = a set = a **horde** (racket auto-fires, finger steers you absolutely). +Odd games end in a **changeover** breather. A won game leaves the wave standing — you must +**clear it by hand** (`G.clearing`) before play advances (`proceedAfterClear`). +Weapons are **only ever won**, in the **sudden-death duel** (`G.interlude`) against the racket +zombie. Getting caught = `G.mode='eaten'` = permadeath, and the run summary is the whole game. -## STATUS — plan v2 COMPLETE + three feedback rounds (2026-07-12) -Everything in PLAN.md is shipped: match structure (games/sets/changeovers, permadeath, -spawn director), bomb juice, new-balls weapon ladder (BOMB→TRIPLE→FIRE→GUN), enemy -roster (runner/brute/shield), game-end sweep + 'GAME N' theater, an always-there -racket-zombie opponent, and the SUDDEN DEATH interlude (intro card, tennis-palette -lerp while the duel runs, real line calls, win = tin on the spot, lose = surge). -Post-plan rounds added: TWO-STAGE serve aiming (touch = reticle + auto-lock, drag = -aim, release = fire, drag-back = cancel; aimed serves commit specials, taps keep them), -the RAF-freeze fix (sweepCourt guts zombies[] under iterating loops → null guards -everywhere a kill can cascade + RAF rescheduled before update), instant absolute -machine-gun steering clamped to the visible court, RES 3 + flatter lens (camZ 42, -camF per orientation in fitViewport), 4-frame walk cycles (z_a/b/f 1–4), tiered gore -(GORE_MUL in zombies.js), generated stadium (x_crowd.png bounce-columns, x_ads.png -sponsor boards, umpire at UMP_X 5.6 — 7.1 was one pixel off-screen in portrait), -runner = glass cannon (any touch kills; runnerVMax cap), battery pass (TUNE.fps knob -default 60 — 30 was vetoed on feel; cached pile canvases). PLAN.md records all -decisions + next-arc candidates. Next: Oliver playtests and tunes via the T panel. +## Architecture & invariants — know these before touching anything -## PREVIOUS TASK — all four items from Oliver's feedback round are DONE and VERIFIED -(serve cadence · flick-aim anywhere incl. over the net · zombie speed ramp · eaten): - -1. **Serve cadence — verified.** Dead splatter ball → 15 frames (0.25s) to next pretoss; - setupPoint leaves P where he stands; FLICK in pretoss fires an instant quick serve - (no toss, pow 0.62, aimed), plain TAP still does the toss ritual. -2. **Aim anywhere — verified.** Flick up targeted a far-side zombie (marker z=5.5 for a - zombie at z=5, over the net), flick down a near-side one (z=18.1). If the lane system - still fights Oliver, next step is true free-aim (flick → landing point, splash radius). -3. **Speed ramp — done.** G.splatT ticks in updateZombies (splatter only); spawnZombie - multiplies speed by `1 + min(1.5, splatT/70)`. Verified 0.76 m/s fresh vs ~1.5 at splatT=140. - Spawn interval / zMax do NOT ramp yet — add if Oliver wants more pressure. -4. **Eaten — done.** updateZombies: zombie within 0.7m of P (splatter only) → gore + 30 - particles + splat(1) + voice('game_set_massacre') + mode 'eaten', ball dead, flash cleared. - gameplay.update early-exits in 'eaten' (ticks idleT/eatenT, still updates zombies — - they shamble on; baseline crossings no longer score). render: P not drawn, dark overlay - EATEN. / games score / TAP TO PLAY AGAIN (restart prompt appears after G.eatenT > 0.7 so - death-spam taps don't skip the screen). main.js: tap/key while eaten → location.reload(). - -ALSO: tennis volley fix (Oliver's follow-up "impossible to volley when the marker is behind -me"): pre-bounce player contact widens reach x1.4 and swing window x1.8 (VOLLEY in config.js, -applied in trySwing + the expiry in update). Root cause was no hard block, just a ~0.28s -no-cue timing window at full ball pace; now ~0.5s. doHit also clears G.wasServe so volleying -a serve back doesn't fault-check the return. CPU still returns 11/12 serves (benchmark holds). - -## Verification recipe (the regression net — use it after the above) -Drive the game deterministically via `window.T` with javascript_tool: the Browser-pane eval -context reports `document.visibilityState==='hidden'`, so RAF is paused and `T.step(1/60)` -gives clean manual stepping. `T.press('Space')` = tap. Dismiss the intro overlay first: -`document.getElementById('intro')?.dispatchEvent(new PointerEvent('pointerdown',{bubbles:true}))`. -- Tennis regression: serve → CPU returns most balls → points/games advance. -- Splatter: `T.G.mode` / force via 3 synthetic pointerdown on the umpire's projected position - (see main.js listener; coords = proj(7.1,0,11.885), remember logical = cv.width/2 because RES=2). - `T.zombies`, `T.spawnZombie(x, z, 'a')`, TUNE.zMax=0 to stop spawns during tests. -- Prior benchmarks that must keep holding: CPU returns ~11/12 serves; assist gets the idle - player in reach 8/8; constructed perfect corner (s=28, g×1.9) is a WINNER, medium center - ball is returned (run scenarios ~12x — single runs are flaky due to 9% aiFlub). - -## Tennis feel — CONFIRMED GOOD by Oliver (2026-07-11), don't regress -- Volleys: pre-bounce contact gets reach x1.4 / window x1.8 (VOLLEY in config.js). -- Half-volleys: a RISING ball is hittable at any height; only a dropping ball below - CONTACT.minY is "past you" (trySwing). This was the big one — the assist parks you on - the landing spot, so feet-bounces were unhittable before. -- Miss diagnostics: every tennis swing logs to T.G.missLog (ring of 50); last miss reason - flashes on the HUD (TUNE.missHud); TAPPING THE SCOREBOARD toggles an on-screen dump of - the last 9 entries — Oliver screenshots that from his phone to report live situations. -- Benchmarks that must keep holding: CPU returns 11/12 serves; 30 simulated points with - human-like tapping (tap when incoming ball d<2.2–3.2) = 50 hits 0 whiffs; feet-bounce - tap sweep ≈ 19/30 winning frames. - -## POLISH ROUND — all 14 items of POLISH-PLAN.md are DONE (2026-07-27) -New foundations you must know before touching anything: -- **`G.modal`** (state.js) is the only thing that stops the world: `update()` early-returns on it - after the flash/card decay, so no zombie steps, no ball flight, no spawn timers — only `idleT` - ticks (for the blink). `showModal`/`dismissModal` in gameplay.js; `drawModal` is the LAST thing - render() draws. Dismissal must SWALLOW the gesture: input.js `touchstart` returns on it, keydown - returns on it, and a `pointerdown` dismisser is registered **only on `pointer: fine`** — on touch, +- **`G.modal` is the only thing that stops the world.** `update()` early-returns on it (after + flash/card decay), so no zombie steps, no ball flight, no spawn timers — only `idleT` ticks for + the blink. `showModal`/`dismissModal` in gameplay.js; `drawModal` is the LAST thing render draws. + Dismissal must **swallow the gesture**: input.js `touchstart` returns on it, keydown returns on + it, and the `pointerdown` dismisser is registered **only on `pointer: fine`** — on touch, pointerdown fires *before* touchstart, so a shared handler cleared the modal and let the touchstart underneath serve. `G.seen.` gates first-time-only modals. -- **`UI` in config.js** is the accent palette (hot/warm/pick/cream/dim/bad/ball). Every HUD colour +- **`UI` in config.js is the accent palette** (hot/warm/pick/cream/dim/bad/ball). Every HUD colour goes through it. Gore reds are deliberately NOT in it — the splatter stays loud. -- **`F(size, bold)` in render.js** builds every canvas font string. Bare `monospace` resolves to - Courier, a slab SERIF — that was the "generic serif" bug, not taste. Never write `ctx.font =` by - hand. Widest string checked: `SERVE FIRES: … TAP THE RACK TO SWITCH` at 259px of 270 portrait. +- **`F(size, bold)` in render.js builds every canvas font string.** Bare `monospace` resolves to + Courier, a slab SERIF — that was the "generic serif" bug, not taste. Never hand-write `ctx.font`. + Widest string checked fits 270px portrait with ~10px spare. - **Weapons are only ever WON.** `deliverTin()` has exactly one caller: `resolveInterlude('P')`. - The old game schedule now calls the DUEL instead (`TUNE.duelFirst`/`duelEvery`, `G.duelDue`), - fired from `proceedAfterClear()` — the one beat where the court is guaranteed empty. The beat it - interrupts is parked in `G.afterDuel` and resumed when the duel resolves. `G.shameGame` is gone. + The schedule (`TUNE.duelFirst`/`duelEvery`, `G.duelDue`) calls the **duel**, fired from + `proceedAfterClear()` — the one beat where the court is guaranteed empty. The game beat it + interrupts is parked in `G.afterDuel` and resumed when the duel resolves. +- **Anything that blocks serving.** If a "can't serve" report ever comes in, check these first — + each is an early return in `keyPressed`: `G.mgT`, `G.horde`, `G.changeover`, `G.interlude.introT`, + `G.modal`. `G.mgT` in particular must keep counting down in EVERY phase (it used to tick only + during `rally`, so a duel calling `setupPoint()` stranded it above zero forever = permanent lock). +- **Animation frames step off DISTANCE COVERED, never elapsed time** — `boy.dist` for ball boys, + `zb.z / TUNE.bruteStride` for brutes. Time-driven cycles moonwalk the moment speed and frame rate + disagree. Sprite heights follow the PNG (`img.height / SPRITE_H`) because `_slice.py` + common-scales a sheet; a wound stage's height must ALWAYS come from its A frame or it pops. - **Fire chains.** `zb.burnT`/`burnD`; `ignite()` is read-only over `zombies[]` so it is safe inside - a kill loop; `burnTick()` returns true when the fire killed the body and the caller MUST `continue`. - Flames are drawn procedurally (`drawFlames`), no sprite sheet. -- **Sprite heights follow the PNG now.** `drawBoy` and the brute wound stages scale by - `img.height / SPRITE_H`, because `_slice.py` common-scales a sheet: a deep mopping lean and a - headless brute are genuinely shorter and were being stretched back to full height. -- Benchmarks re-run and holding: CPU returns 23/24 serves; 30 human-tapped points = **50 hits / - 0 whiffs** — but you MUST set `T.TUNE.autoAssist = 1.15` first, the Browser pane reports - `pointer: fine` so the assist is off and every ball reads as "never reachable". -- Brute is 5 hp: worst-case (max ramp) it still needs 22.5s to cross, vs ~7s of serves to kill. + a kill loop; `burnTick()` returns true when the fire killed the body and the caller MUST + `continue`. The flame ball also ignites what it FLIES PAST (`TUNE.burnBallR`) — without that its + full pierce killed its own lane outright and nothing was left to catch. Flames are drawn + procedurally (`drawFlames`), no sprite sheet. +- **Any loop over `zombies[]` whose body can kill** can cascade into a sweep and gut the array + mid-iteration — keep the `if (!zb) continue` guards and iterate backwards. +- **The ball boy is ONE slot.** `sendBoy` never replaces a boy already on court (a kill streak used + to teleport him back to the wing several times a second); new work is appended via `jobs`. + `mopRound` walks him to a stain, holds him there for `TUNE.boyMopDwell` while the push cycle + animates in place, then moves him on. Three sheets: `x_boymop` (stopped, working), + `x_boycarry` (walking with the mop shouldered), `x_boyrun` (tennis-mode fetch). +- **Crowd applause is a real two-frame swap** (`x_crowd` seated / `x_crowd2` arms up), per column on + its own phase, with **no vertical offset**. A fractional `dy` on a scaled image re-snaps its pixel + rows every frame under nearest-neighbour — that boiling beside the HUD was the "scoreboard + flicker" (the board art is 99.9% opaque; nothing was ever showing through it). + **If you touch one crowd frame you must apply the identical curve to the other**, or the swap + starts flashing brightness. `assets/_sheet_crowd1.png` is the pre-brightening original. -## Feedback round on the polish (2026-07-27, same day) -- **Fire never spread in real play.** The flame ball has full pierce and instant-kill, so it wiped - its own lane and left nobody to catch. Fixed by `TUNE.burnBallR` — the ball ignites everything it - FLIES PAST, every frame, whether or not it hits — plus wider `burnIgniteR` 4.5 / `burnSpread` 2.2. - One flame serve into a realistic scattered 10-wave now kills 9, with 9 burning at once. - **When testing fire, fire a real ARC**: a flat ball from z=16 hits the net at y≈0.83 (netH 0.914) - and bounces back at you — the first "fire is broken" reading was my test hitting the tape. -- **Wounded brutes need their own animation.** Every wound stage is a single frame and the brute - now spends most of its life wounded, so it slid along like a decal. Rather than 8 more sprites - they get a procedural lumber in `drawZombie` (translate + squash, no rotation, so pixels stay on - the grid). Amplitudes MUST scale with `p.s` — at a fixed fraction the whole lurch was sub-pixel - at range and read as nothing moving. -- **The scoreboard "flicker" was the crowd.** The cheer bounce was `-Math.random()*2` per column - per frame, i.e. TV static, showing through the scoreboard art's transparent ornate edges. Now a - smooth per-column sine on `idleT`. Test for it: render twice WITHOUT stepping — identical pixels - means nothing in the draw path is rolling dice per frame. -- Ball boys: `sendBoy`'s `mop` now DEFAULTS to `G.mode === 'splatter'`. The running fetch-boy still - exists, but only in tennis mode where there is nothing on the grass but a ball. `BOY_H` 1.2 → 1.5. -- `TUNE.burnSpread` is a T-panel knob if Oliver wants fire hotter still. +## Settled feel decisions — do not regress silently +- **Two-stage serve**: touch = reticle, drag = aim, release = fire, drag back under `pullMin` = + cancel. The pull's DIRECTION picks the lane, its LENGTH is power — deliberately decoupled so a + far shambler never costs a longer drag than the screen has. +- **The manual wave clear** after a won game (`G.clearing`). Oliver was emphatic. +- **Thin, dynamic blood.** Open wounds gush live particles (`bleed()`); blood is never painted into + a sprite. Gore piles/stains are the loud part; keep the trickle sparse. +- **One gesture means one thing** on touch. No mode where a drag silently becomes something else. +- **Volleys**: pre-bounce contact gets reach ×1.4 / window ×1.8 (`VOLLEY`). A RISING ball is + hittable at any height; only a dropping ball below `CONTACT.minY` is past you. +- **Aim magnet**: `flickTarget` picks the best-ALIGNED body, with `TUNE.aimTie` of slack in which a + nearer one still wins, so a straight-ahead lane still eats the nearest body first. + `aimTie: 1` restores the old "nearest along the lane always wins" for A/B. +- `TUNE.fps` 60 — 30 was vetoed on feel. -## Second feedback round — "do it properly" (2026-07-27) -Oliver rejected the procedural fakes on sight. The rule this round: **animation gets drawn frames.** -- **Every brute wound stage now has a 2-frame lurch** (`z_gw`/`z_gwb`, `z_gw2`/`z_gw2b`, …). The - procedural squash-and-sway that replaced them read as fake immediately — "that's where the comedy - comes from". Generated by handing the generator a composite of the four A-frames - (`assets/_sheet_gwound_a.png`, built with PIL) and asking for the stride swapped. - **Gotcha:** the generator gave every B frame a dragged pole, but only stage 1's A frame has one — - it would have blinked in and out each step. Stripped by keeping the largest connected blob - (stages 2–3) and, where the pole touched the body (stage 4), by cutting on column density: the - torso is dense, a dragged pole is a thin diagonal of ~7 px per column. -- **Frames step off DISTANCE COVERED, never elapsed time** — `boy.dist` for ball boys, - `zb.z / TUNE.bruteStride` for brutes. Time-driven cycles moonwalk whenever speed and frame rate - disagree, which is exactly what "he slides faster than the animation suggests" was. - Height for a wound stage ALWAYS comes from the A frame or the sprite pops size every step. -- **One boy, one slot.** `sendBoy` no longer replaces a boy who is already out — that was the - "appears and disappears": every kill teleported him back to the wing, so a kill streak restarted - him several times a second. New work is appended to his round (`jobs`). One crossing is now 10.5s. -- **The applauding crowd is a real two-frame animation** (`x_crowd` seated / `x_crowd2` arms up), - swapped per column on its own phase. The previous "smooth wave" still shifted each column by a - FRACTIONAL dy, and nearest-neighbour resampling of a scaled image at a fractional y re-snaps its - pixel rows every frame — that boiling beside the HUD was the "scoreboard flicker". The board art - is 99.9% opaque, so nothing was ever showing *through* it. **Test:** render twice without - stepping — identical pixels means nothing in the draw path rolls dice; and with the crowd calm - the frame-to-frame pixel delta must be 0.0%. -- Editing a full-scene image (`x_crowd` → `x_crowd2`) preserved the layout far better than expected: - mean difference 3.2/255, railing and tier rows unmoved. Worth reusing for other 2-frame swaps. +## Verification recipe (the regression net) +Drive the game deterministically via `window.T` with `javascript_tool`. The Browser-pane eval +context reports `document.visibilityState === 'hidden'`, so RAF is paused; halt it anyway with +`window.requestAnimationFrame = () => 0` and step with `T.step(1/60)`. `T.press('Space')` = tap. +Clear the intro first: `document.getElementById('intro')?.remove()`. +- **You MUST set `T.TUNE.autoAssist = 1.15` first** — the Browser pane reports `pointer: fine`, so + the mobile assist is off and every ball reads as "never reachable". +- Force splatter with `T.forceZombies()` then ~600 steps (in splatter it forces a HORDE instead). +- `T.zombies`, `T.spawnZombie(x, z, 'a')`, `T.TUNE.zMax = 0` to stop spawns during tests. +- Also exposed: `T.explode`, `T.splatPoint`, `T.startDuel`, `T.resolveInterlude`, `T.deliverTin`, + `T.flickTarget`, `T.showModal`/`T.dismissModal`, `T.render`. -## Third feedback round (2026-07-27) -- **THE FREEZE (serving dead, gun racket stuck over your head).** `G.mgT` only counted down inside - `if (G.mgT > 0 && G.phase === 'rally')`. Anything that moved the phase mid-burst — a scheduled - duel calling `setupPoint()` — stranded `mgT` above zero *forever*, and `mgT > 0` is one of - `keyPressed`'s early returns, so no tap could ever serve again. The yellow blob Oliver saw was - `drawMgRacket`'s muzzle flash still being drawn. Fixed at the root (the clock ticks in every - phase; only the pellets are gated on 'rally') **and** semantically (`startDuel` cancels any - burst — the duel is tennis). If another "can't serve" report ever comes in, check these flags - first: `G.mgT`, `G.horde`, `G.changeover`, `G.interlude.introT` — each one blocks `keyPressed`. -- **The mop boy has a job now** (`mopRound` in gameplay.js): walk to the biggest nearby stain, - stand on it for `TUNE.boyMopDwell` while the push cycle animates in place, scrub it out, pick the - next, then walk off. `TUNE.boyMaxJobs` caps the round so a kill streak can't employ him forever. - Gore fades ~3.5× faster while he is actually stopped and mopping than when merely walking past. - A straight-line constant-speed crossing was what read as moonwalking past the mess. -- **Applause is floored at the audio length.** `assets/audio/tennis-applaus.wav` is 4.16s and always - plays out in full, but every `cheer()` call passed a shorter beat, so the stand sat down while the - clap was still audible. `cheer()` now does `Math.max(crowd.cheer, seconds, APPLAUSE_T)`. -- **Regenerating a crowd frame: colour-match it or don't ship it.** The bolder re-roll came back - much brighter and bluer; swapping to it would have flashed the whole stand. Fixed by a per-channel - linear map onto frame one's mean and sd (means now agree to within half a level), then quantised - to 64 colours. Verify with a mean-difference number before trusting any regenerated frame. -- **Net and umpire were both wrong since forever.** `COURT.netW` was the singles-stick width 10.06 - while the court is drawn to the doubles lines, so the net stopped 0.46 m short each side — now - 10.97, flush. `UMP_X` 5.6 put the chair's near legs 0.45 m *inside* the doubles line (x_ump is - 42×112 px at UMP_H 3.0 = 1.12 m wide as drawn); 6.05 stands them exactly on the line. Do not push - past ~6.3 or the chair clips the right edge of the portrait viewport. - The wider net is a real physics change (balls between |x| 5.03 and 5.485 now catch the tape); - serve returns measured 134/150 = 89.3% ±2.5% afterwards, bar still inside one sigma. +**Benchmarks that must keep holding** (re-run after any physics/aim change): +- CPU returns ~11/12 serves (91.7%). Recent 150-sample runs: 90.7%, 89.3%, 90.7%, 88.7% (±2.5%). + Watch this — the last few sit under the bar rather than scattered around it. +- 30 human-tapped points (tap when the incoming ball is 2.2–3.2 m away) = **50 hits / 0 whiffs**. +- A 26-game state-machine soak with bombs/bursts/fire must finish with no exceptions, no stall, + `mgT` back to 0, and gore/piles arrays bounded. +- **Never trust a single sample.** aiFlub is 9%; a 20/24 run is normal noise. Use 150. -## Fourth feedback round — feel polish (2026-07-27) -- **The draw meter is a fixed bar across the bottom** (~90% of W, 10px tall, `METER_Y()` in - render.js), not a 26×4 stub floating on the apron by the player's feet. The serve hint is - suppressed while `G.aim` is live because they share that slot. `TUNE.pullFull` 95 → 130: at 95 - every pull maxed out by accident and the meter meant nothing. Don't push much past ~150 — a pull - that STARTS low on the screen still has to be completable before the finger runs off the glass. -- **Aim magnet.** `flickTarget` used to take the nearest body *along* the lane, so a close shambler - a little off-axis stole shots clearly aimed past it. It now picks the best-ALIGNED body - (normalised offset within the cone), with `TUNE.aimTie` (0..1) of slack in which a nearer body - still wins — so a straight-ahead lane still eats the nearest one first, which is the settled feel. - `aimTie: 1` restores the old "nearest always wins" exactly, for A/B. -- **Bomb**: radius 2.5 → 4.2 (2 kills → 5 in a scattered 10-wave), 120-particle eruption, a ring of - thrown stains, size-4 pile. The white-out (`rgba(255,255,255,0.85)`) is now a brief warm bloom - (`rgba(255,150,70,0.3)`) — the flash was hiding the very gore it announced. -- **Rack touch target**: `rackH` 22 → 30 and `rackHit` pads the touch band by `RACK_GRAB` above and - runs to the screen edge below. 22 logical px is ~30 CSS px on a phone, well under the ~44 a thumb - expects; it's now ~62. Rack text is positioned proportionally (`g.h * 0.33` / `0.72`) so the tile - height stays a free knob. +## Asset pipeline +1. `generate-image` skill (needs `OPENROUTER_API_KEY` in the gitignored repo `.env`; run with the + scratchpad venv python — system python lacks `requests`). Passing the existing sheet as + `--input` (edit mode) nails the style and is how every matching sheet here was made. +2. Ask for a **magenta background** and, for sheets, "at least 150px of empty magenta between + frames" plus "the prop must stay inside its own frame". +3. `python3 assets/_slice.py sheet.png out1.png ...` — chroma-key on the corner colour, magenta + de-fringe, column-gap segmentation, common-scale per sheet, `IDLE_TARGET_H=112`. `--largest` + keeps only the main blob. +4. Standalone art (logo, popup panels) uses `assets/_key.py` instead: chroma-key + crop to the + content bbox + optional downscale, no frame splitting, no forced height. +5. Register new names in `render.js`'s `SPR` preload array. -## Fifth feedback round (2026-07-27) -- **Crowd brightness**: both crowd sheets got an identical gamma-0.8 lift (means 30/14/39 → - 42/22/56) and the splatter tint dropped 0.5 → 0.26. **If you ever touch one crowd frame you must - apply the exact same curve to the other**, or the clap swap starts flashing brightness. - `assets/_sheet_crowd1.png` is the pre-lift original. -- **The splatter serve hint led with the tap** ("TAP TO SERVE · PULL BACK TO AIM") when pulling is - the entire gesture. Now "PULL BACK TO AIM · RELEASE TO FIRE", plus `drawPullHint()`: after - `TUNE.pullHintAfter` seconds of sitting in the serve stance untouched (`G.idleHintT`), a ghost - thumb slides back while chevrons grow up-court, on a loop. It teaches the inverse relationship - the text can't. -- **The mop boy has three sheets now**, not two: `x_boymop` is the push cycle used ONLY while - stopped and working, `x_boycarry` is him walking upright with the mop shouldered, `x_boyrun` is - the tennis-mode fetch. He was mopping the air the whole way to the stain. +Known generator quirks, all still true: +- It sometimes ignores the magenta (harmless — the key uses the corner colour). +- It draws near-identical animation frames unless you describe strongly contrasted poses. +- **It adds props the A frames don't have** (a dragged pole on every brute B frame, a mop head). If + the prop is a separate blob, keep the largest connected component; if it touches the body, cut on + column density — a torso is dense, a dragged pole is a thin diagonal of ~7 px per column. +- **If frames merge** (a handle bridging the gap) `_slice.py` reports "found 1 frames". Re-prompt + for wider gaps first; if that fails, cut at the true column gaps plus the thinnest column inside + the merged pair, then common-scale. +- **Colour-match any regenerated frame** of an existing pair before shipping: fit a per-channel + linear map onto the original's mean and sd, then verify with a mean-difference number. ## Gotchas learned the hard way -- Browser-pane verification: python http.server sends no cache headers, so edited ES - modules often load STALE even after location.reload(). Reliable fix: preview_stop + - preview_start (a new port = new origin = cold cache). Always sanity-check a just-added - field exists (e.g. `T.G.newField !== undefined`) before trusting test results. -- Sprite pipeline: generate via generate-image skill (needs OPENROUTER_API_KEY in repo .env, - gitignored; run the script with the scratchpad venv python — system python lacks requests) - → magenta background → assets/_slice.py (COMMITTED now, no more scratchpad rebuilds: - chroma-key on the corner color + magenta de-fringe, column-gap segmentation, --largest - keeps only the main blob, common-scale per sheet, IDLE_TARGET_H=112). Generator sometimes - ignores magenta (harmless — the key uses the corner color) or draws near-identical - animation frames (re-prompt with strongly contrasted pose descriptions). If two frames - touch (a dragged weapon bridging the gap), split the sheet at the midpoint and slice each - half with --largest. Passing the existing sheet as --input (edit mode) nails the style. -- drawChar mirrors sprites by ch.facing — any stance that must not mirror needs facing reset - (setupPoint pins facing=1). -- Standalone art (logo, popup panels) uses `assets/_key.py` instead of `_slice.py`: chroma-key + - crop to the content bbox + optional downscale, with NO frame splitting and NO forced sprite - height. `python3 assets/_key.py sheet.png out.png [target_width]`. -- If a generated sheet's frames TOUCH (a mop handle bridging the gap), `_slice.py` finds 1 frame - and there is no column of magenta to split on — re-prompt for "at least 150px of empty magenta - between frames" and "the prop must stay inside its own frame". That worked; hand-splitting did not. -- The scratchpad venv/scripts get wiped between sessions — recreate venv (requests+pillow) - and slice.py lives only in scratchpad; its logic is described above if gone. -- Don't trust single-sample gameplay sims; randomness (aiFlub 9%, aim error) needs ~12 runs - (a 9/12 serve-return run is normal flub noise — re-run 24 before suspecting a regression). -- Synthetic touch tests: input.js only sets stick.moved in touchmove, so a - touchstart→touchend pair reads as a TAP — always dispatch a touchmove between them. -- Contact/kill tests must step 1/60: pellets at 26 m/s tunnel past 0.55m radii at - coarser steps and kills silently don't happen (looks exactly like a bug). -- preview_start autoPort only yields a fresh origin if the configured port is BUSY — - hold 8641 with a throwaway socket first, or you get stale cached modules again. -- Any loop over zombies[] whose body can kill (killZombie/splatter) can cascade into - sweepCourt and gut the array mid-iteration — keep the `if (!zb) continue` guards. -- Commit style: imperative summary + Co-Authored-By Claude line; push = deploy. +- **Stale ES modules are the #1 time sink.** python http.server sends no cache headers, so an edited + module often loads STALE. `preview_stop`/`preview_start` only helps if the port actually changes, + and holding 8641 with a throwaway socket **did not work reliably**. What DOES work: + ```js + await Promise.all(['main.js','state.js','gameplay.js','render.js','config.js','zombies.js', + 'input.js','audio.js','panel.js','dom.js'].map(f => fetch(f, {cache:'reload'}))); + ``` + then reload the page. **Always sanity-check a just-added field before trusting any result** — + and note the trap: if `state.js` is stale but `gameplay.js` is fresh, a new `G` field + materialises as **NaN** (undefined + dt), which reads exactly like a logic bug. +- `drawChar` mirrors sprites by `ch.facing` — any stance that must not mirror needs facing reset. +- Synthetic touch tests: input.js only sets `stick.moved` in touchmove, so touchstart→touchend + reads as a TAP. Always dispatch a touchmove between them. +- Contact/kill tests must step 1/60 — pellets at 26 m/s tunnel past 0.55 m radii at coarser steps + and kills silently don't happen, which looks exactly like a bug. +- **When testing fire or any serve, fire a real ARC.** A flat ball from z=16 hits the net + (netH 0.914) and bounces back at you; a "the weapon is broken" reading was my test hitting the tape. +- The scratchpad venv gets wiped between sessions — recreate it with `requests` + `pillow`. +- Commit style: imperative summary + `Co-Authored-By: Claude Opus 5 `. + Push = deploy. + +--- + +# OPEN ISSUES — from Oliver's 35-game retest (2026-07-27) +Nothing below is fixed. Diagnoses are from reading the code this session; verify before trusting. + +### 1. Player frozen (and stuck mid-animation) during the between-game pauses — HIGH +During the ~5s changeover the player cannot move but the zombies keep walking. The whole movement +block, including the `P.pose = moving ? 'run' : 'idle'` update, lives inside +`if (G.phase === 'rally')` in `update()`. Every 'over' beat (changeover, game beat, set card) +therefore freezes the player in whatever pose he held while `updateZombies` runs on. +**This is almost certainly also issue 6** (freeze after the horde) — `endHorde` hands back to a +phase where the movement block doesn't run. +Oliver's stated options: *drop the 5s entirely, or stop the zombies for its duration.* Stopping the +zombies is the more characterful of the two (the boys mop, everyone waits) but letting the player +walk during 'over' in splatter is the smaller diff and fixes the stuck pose too. Consider both. + +### 2. The mop boy walks on during the 1:1 duel — LOW +`sendBoy` has no `G.interlude` guard and `startDuel` doesn't clear `G.boy`. The duel is meant to be +clean tennis; nobody should be mopping through it. + +### 3. Triple serve: the two wing balls lob off at the wrong height — MEDIUM +Confirmed cause. The real serve carries topspin gravity `ball.g = TUNE.gravity * (1 + quality * +TUNE.serveSpin)` — up to **2.3×** `TUNE.gravity` at full power. The clones in `extras` are +integrated at flat `TUNE.gravity` (`zombies.js`, the `for (let i = extras.length - 1 …)` loop: +`e.vy -= TUNE.gravity * dt`). So the centre ball dives and the wings float long. +Fix: copy `g: ball.g` onto each extra where they're pushed (`gameplay.js` in `hitServe`, and the +re-fire clone lower down) and integrate with `e.g || TUNE.gravity`. + +### 4. The umpire calls "Time" on pauses that aren't changeovers — LOW +`voice('time')` fires in the `over` branch of `update()` whenever `G.changeover` clears. Needs to +distinguish a real changeover from other forced waits, or the line needs to move. + +### 5. The transition tutorial illustration confuses people — MEDIUM +`assets/x_howto.png`. The curved arrow plus thumbprint reads as a rounding/scroll gesture rather +than a pull-back. Oliver: *"maybe display a pulling and aiming finger?"* Worth regenerating as a +literal finger pressing down and dragging back with a motion trail, and re-checking it at the +modal's drawn size (~89×120 logical px in portrait) rather than at full resolution. +Note `drawPullHint()` now demos the gesture live in-game, so the illustration can lean on that. + +### 6. Sprite freeze after the horde — HIGH +See issue 1; very likely the same root cause. + +### 7. Aiming plays the toss animation; it should stay in the pretoss stance — MEDIUM +`input.js` touchmove calls `serveToss()` on the first drag, which sets `G.phase = 'toss'`, and +`update()` forces `sv.pose = 'serve'` for the whole of `phase === 'toss'`. So the player is in the +toss/serve pose the entire time he is aiming. Oliver wants the **pretoss** stance while aiming, with +the toss/smash playing when the shot actually fires. + +### 8. Show the current game number persistently — LOW +Today it only appears as a 1.1s `GAME N` flash when the next game arms. Wants it visible somewhere +permanent — the scoreboard is the obvious home (it already has a sets column in splatter). diff --git a/POLISH-PLAN.md b/POLISH-PLAN.md deleted file mode 100644 index 0e22036..0000000 --- a/POLISH-PLAN.md +++ /dev/null @@ -1,529 +0,0 @@ -# Splatter Tennis — Polish & Bugfix Plan - -Written 2026-07-27 for a fresh Claude session. Everything below was verified against the -code at commit `c8990fe`; every "cause" line is something I actually read, not a guess. - -**Read first:** `HANDOFF.md` (architecture, benchmarks, hard-won gotchas). This plan assumes it. - -## How to work in this repo - -- Zero build. Plain ES modules served by `python3 -m http.server` via `.claude/launch.json` - (`preview_start` with `{name: "splatter-tennis"}`). -- `config.js` = all tuning (`TUNE` is live-editable in-game via the `T` panel, which - auto-lists every key — new keys appear for free). `state.js` = all mutable state on `G`. - `gameplay.js` = rules/physics/update. `render.js` = all drawing. `zombies.js` = the splatter - sim. `input.js` = keys + touch. `main.js` = boot + the `window.T` test surface. -- **Stale modules are the #1 time sink here.** After editing, bump `main.js?v=N` in - `index.html` AND navigate to a fresh page URL (`http://localhost:8641/?cb=`). Then - sanity-check freshness before trusting any result: `T.TUNE.someNewKey !== undefined`. - Bumping `?v=` also guarantees Oliver's phone picks up the build. -- RAF pause state varies per session. Probe it; halt with - `window.requestAnimationFrame = () => 0` before deterministic `T.step` tests. -- Synthetic touch needs a `touchmove` between start and end or it reads as a tap. Contact - tests must step `1/60` (coarser steps tunnel). -- Verify each item with a runnable check before moving on. Screenshot the visual ones at - `resize_window` mobile (375×812) — this is a phone game. - -## Order of work - -Items 1–3 are **foundations** that later items depend on. Do them first, in order. -Items 4–13 are independent after that and can be done in any order. - ---- - -# 1. FOUNDATION: the modal system - -Five separate requirements ("popup, game only continues after tapping it away") need the -same thing, which does not exist yet. Build it once. - -**What exists today:** `G.card` (`state.js:60`) + `drawCard()` (`render.js:326`) draw a -centred banner, but they are purely cosmetic — the game keeps running underneath on an -`overT` timer. There is no input-gated pause anywhere. - -**Build:** - -`state.js` — add to `G`: -```js -modal: null, // {title, body: [lines], icon, color, onDismiss} — blocks play until tapped -seen: {}, // one-shot markers so a modal only fires the first time this run -``` - -`gameplay.js` — add near the card helpers: -```js -export function showModal(m) { if (!G.modal) G.modal = m; } -export function dismissModal() { - const m = G.modal; if (!m) return false; - G.modal = null; if (m.onDismiss) m.onDismiss(); - return true; // tells the caller the gesture was consumed -} -``` - -`gameplay.js update()` — freeze at the very top, after the flash/card decay so nothing -expires while the player reads: -```js -if (G.modal) { G.idleT += dt; return; } // everything else waits -``` -Put this AFTER the `hitstop` and `eaten` early-returns and AFTER `G.flash`/`G.card` decay, -but BEFORE the movement block. Rationale: the world must be completely frozen (no zombie -steps, no ball flight, no spawn timers) or a player reading a popup gets eaten. - -`input.js` — dismissal must take priority over everything and swallow the gesture: -- In `touchstart`, first line inside the `for` loop: `if (dismissModal()) return;` -- In the `keydown` handler: any key dismisses. -- Guard `canAimServe()` with `!G.modal`. - -`main.js` — `maybeRestart` must not fire through a modal. - -`render.js` — a `drawModal()` drawn LAST (after the cards, before nothing). Spec: -- Full-screen scrim `rgba(8,6,10,0.82)`. -- Optional icon sprite centred above the title (used by the weapon popup). -- Title in the UI accent colour, body lines below, `TAP TO CONTINUE` blinking at the bottom. -- Use the pixel font (see item 3) — this is new text, so do not introduce more `monospace`. - -**Acceptance:** with a modal open, `T.step(1)` × 60 must leave `zombies[0].z`, `ball.z` and -`G.splatT` unchanged; a synthetic `touchstart` clears `G.modal` and does NOT serve. - ---- - -# 2. FOUNDATION: UI colour palette - -**Requirement:** "Farbpalette etwas absoften (sehr starke rot und gelb töne in schrift und -interaktiven elementen)". - -**Cause:** the hot colours are hard-coded as hex literals scattered through `render.js`. -`grep -o "#[0-9a-fA-F]\{6\}" render.js | sort | uniq -c` gives the inventory. The offenders: - -| current | where | proposed softer | -|---|---|---| -| `#f8e048` | aim reticle, hints, NEW BEST, TAP TO PLAY AGAIN (5×) | `#e0cc72` | -| `#ff5050` | locked-target reticle, out-marker, SUDDEN DEATH card (3×) | `#d9705f` | -| `#e6ee3a` | selected rack slot, SET card (4×) | `#cbd06a` | -| `#e8f03a` | the ball itself (2×) | `#dbe063` | -| `#c02020` | GAME SET MASSACRE | `#a34a42` | -| `#e0231a` / `#eef23a` | intro logo CSS in `index.html` | see item 10 (logo art replaces it) | - -**Do NOT** soften the gore reds (`PAL`/blood particles) — the splatter should stay loud; -only text and interactive UI were named. - -**Build:** add a `UI` export to `config.js` next to `TUNE` (a plain object, not inside -`TUNE` — the T panel only renders numbers): -```js -// UI accent colours, one place so the whole HUD can be re-tuned at once. Deliberately -// desaturated from the original arcade primaries: they were shouting over the artwork. -export const UI = { hot: '#d9705f', warm: '#e0cc72', pick: '#cbd06a', cream: '#e8e8dc', - dim: '#8fa0b8', bad: '#a34a42', ball: '#dbe063' }; -``` -Then replace the literals in `render.js`. Keep one commit for the mechanical swap so a -revert is trivial. - -**Acceptance:** screenshot before/after at mobile size; no remaining `#f8e048` / `#ff5050` -/ `#e6ee3a` / `#c02020` in `render.js`. - ---- - -# 3. FOUNDATION: the serif font bug - -**Requirement:** "Die Serifen-Schrift ist nicht schön. Die sieht so generisch aus." - -**Cause — this is a real bug, not taste.** `index.html` bundles Silkscreen and `main.js` -warms it, but only 4 of 15 canvas font strings actually ask for it. Everything else says -`bold Npx monospace`, which resolves to Courier — a slab **serif**. Confirmed: -`grep -n "ctx.font = " render.js` → lines 329, 330, 560, 562, 566, 569, 572, 575, 588, 597, -603 all use bare `monospace`. Lines 314, 316, 534 correctly use `Silkscreen`. - -**Build:** `render.js:534` already has the helper — -`const F = (sz, b = 1) => \`${b ? 700 : 400} ${sz}px Silkscreen, monospace\`;` -Hoist it to module scope (it currently lives inside the scoreboard block) and route **every** -`ctx.font =` through it. Silkscreen has no lowercase-friendly metrics at odd sizes; keep the -existing px sizes but expect slightly different widths — re-check the centred strings -(`GAME, SET, MASSACRE.` at 16px is the widest) still fit at 270 logical px wide (portrait). - -Also fix the `#hint` line in `index.html` (`font: 12px monospace`) — same serif, and it is -visible under the canvas on desktop. - -**Acceptance:** `grep -c "px monospace" render.js` returns 0 except inside `F`'s fallback -chain. Screenshot the eaten screen and a card in portrait; no serifs, nothing clipped. - ---- - -# 4. BUG: weapon stock caps at 3 - -**Requirement:** "es können pro waffe immer nur drei gehalten werden. es sollten mehr waffen -angespart werden können." - -**Cause:** `config.js:68` `wCap: 3` and `gameplay.js:336` -`const add = k => Math.min(TUNE.wCap, k);` inside `deliverTin()`. - -**Build:** raise `wCap` to `9`. Check the rack renderer handles two-digit counts: -`render.js` draws `'×' + n` in the slot at 6px — at 270px wide with 5 slots each slot is -~50px, so `×9` is fine but verify `×12` if you go higher. Recommend 9 and stop. - -While here: `deliverTin()` gives `TUNE.bombMax` (3) bombs but hard-codes `3` triples and -`3` flames and `1` gun. Pull those into `TUNE` (`tinTriples`, `tinFlames`, `tinGuns`) so the -economy is tunable from the panel like everything else. - -**Acceptance:** `T.deliverTin()` four times in a row, then check -`T.G.bombs > 3`. Rack renders the count without overflowing its slot. - ---- - -# 5. BUG: weapons arrive whether or not you win the duel - -**Requirement:** "die waffen bekommt man unabhängig ob man das zombie 1:1 match gewonnen hat -oder nicht." - -**Cause — there are two independent delivery paths and only one is gated on the duel:** -1. `resolveInterlude('P')` → `deliverTin()` (`gameplay.js:355`) — the reward for winning. -2. `splatPoint()` sets `G.tinDue` on a game schedule (`ballChangeFirst`/`ballChangeEvery`), - and `startChangeover()` (`gameplay.js:310`) calls `deliverTin()` when `tinDue` — with no - reference to any duel. This is the leak. - -Note path 2 has a partial gate already: `G.shameGame` withholds the tin if *they* took a -game since the last delivery. That is not the same as winning the duel. - -**The design decision (Oliver, 2026-07-27):** duels must come round on their own after a -while, "damit es immer die chance auf waffen gibt". So the schedule is not the problem — it -is attached to the wrong event. **Move it from delivering tins to calling duels.** The -cadence stays, the reward becomes something you have to earn, and the umpire-restores-order -framing is unchanged. - -**Why the duel is rare today:** the trigger at `gameplay.js:~760` requires -`zombies.length === 1 && zombies[0].variant === 'r' && !zombies[0].dueled` — the court has to -be completely empty apart from one never-yet-dueled racket zombie. Racket zombies only appear -when you win a game (`splatPoint`) or survive a horde (`endHorde`), and fresh shamblers spawn -again the moment a clear finishes. So it fires by luck. - -**Build:** -- Rename the schedule to what it now schedules: `TUNE.ballChangeFirst`/`ballChangeEvery` → - `duelFirst`/`duelEvery`, `G.tinDue` → `G.duelDue`. Keep the same arithmetic in - `splatPoint()`. Suggested start: first duel after game 2, then every 3. -- Delete the `deliverTin()` call in `startChangeover()` (keep the "CHANGEOVER" flash). - `G.shameGame` — which withheld a tin if they took a game — has nothing left to gate; - remove it rather than leaving vestigial state. -- Keep `resolveInterlude('P') → deliverTin()` as the only weapon source. -- **Fire the scheduled duel off the existing clear beat.** `proceedAfterClear()` - (`gameplay.js:290`) already runs at the one moment the court is guaranteed empty — the - player just cleared the wave by hand, and no new shamblers have spawned yet. That is the - natural slot. There: if `G.duelDue`, consume it, ensure a racket zombie exists - (`spawnRacketZombie()` if none, and clear `dueled` on the existing one so a veteran can be - challenged again), and start the interlude instead of the changeover/game beat. The - changeover follows after the duel resolves. -- Leave the existing opportunistic trigger in place. The schedule is a floor, not a ceiling — - a lucky clear can still produce a bonus duel, which is pure upside for weapon supply. - -**Why this slot and not a timer:** clearing the wave is already mandatory before play -advances (`G.clearing`, a settled feel decision Oliver was emphatic about), so the schedule -can never stall. A wall-clock timer would need its own "wait for a safe moment" logic and -could fire mid-swarm. - -**Acceptance:** -- Simulate ~10 games. A duel must occur at game 2, 5, 8 … regardless of how the waves fall. -- Lose a duel → `G.bombs`/`triples`/`flames`/`guns` unchanged, and the next duel still - arrives on schedule. -- Win one → exactly one tin, and the item-8 modal names it. -- No path reaches `deliverTin()` except `resolveInterlude('P')` - (`grep -n "deliverTin" *.js` should show the definition, that one call, and the `window.T` - test export — nothing else). - ---- - -# 6. POPUP: entering splatter mode from tennis - -**Requirement:** popup with an **illustration** explaining the new mechanic, stays until -tapped. - -**Trigger:** `gameplay.js:410`, inside `startTransition()`'s timeline — -`once('go', 8.2, () => { G.mode = 'splatter'; ... })`. Fire the modal there, after -`setupPoint()`. - -**Content:** -- Title: `THE MATCH HAS CHANGED` -- Body: pull back to aim · the further you pull, the harder it hits · a weak shot only - staggers them -- Icon: a generated illustration (see Appendix A). Ask for a pixel-art panel showing the - player pulling the slingshot aim back with a chevron trail toward a shambler — it should - teach the gesture, not just decorate. - -Because this is the mechanic tutorial, it is worth the biggest illustration of the set. -Consider a wider modal layout for this one (`icon` sized ~120×80 logical px). - -**Acceptance:** enter splatter; the world is frozen and no zombie moves until tapped. - ---- - -# 7. POPUP: entering the 1:1 duel (first time) - -**Requirement:** popup saying it is a normal tennis game again and that winning earns a -weapon. "momentan gibt es schon ein popup aber auf dem steht auch anderer unnötiger kram." - -**Cause:** `render.js:593` draws -`drawCard('SUDDEN DEATH', 'WIN THE POINT · THE LINES ARE LIVE', 'THE POINT WINS YOU A NEW WEAPON', '#ff5050')` -on a 2.4s timer (`G.interlude.introT`, set at `gameplay.js:764`). It is not dismissible, it -disappears on its own, and "THE LINES ARE LIVE" is the unnecessary cruft. - -**Build:** replace the timed card with a real modal, gated on `G.seen.duel`: -- Title: `SUDDEN DEATH` -- Body: `IT'S TENNIS AGAIN — ONE POINT` / `WIN IT AND THEY HAND YOU A WEAPON` -- On dismiss: start the point. -- Delete the `introT` card path in `render.js` and the `introT` countdown in `update()` if - the modal fully replaces the beat. Keep `G.interlude.introT` only if the umpire voice line - still needs the pause — check `voice('sudden_death', 2.4)` timing at `gameplay.js:~766`. -- Subsequent duels in the same run: no modal, keep a short flash. - -**Acceptance:** first duel opens a modal that blocks serving; second duel does not. - ---- - -# 8. POPUP: weapon received - -**Requirement:** shows and describes the weapon; game continues only after tapping. - -**Trigger:** inside `deliverTin()` (`gameplay.js:335`), replacing the -`G.flash = { text: 'NEW BALLS: ' + SLOTS[w+1] }` line. - -**Content:** icon = the existing rack sprite (`w_bomb` / `w_triple` / `w_fire` / `w_gun` — -already loaded in `render.js`'s `SPR`), title = the weapon name, one line of description: - -| slot | name | line | -|---|---|---| -| 1 | BOMB | detonates on contact — clears a crowd | -| 2 | TRIPLE | one serve, three balls | -| 3 | FIRE | kills on any touch and **sets the others alight** (item 12) | -| 4 | GUN | hold the line and spray — a burst, not a serve | - -**Careful:** `deliverTin()` is also called from `resolveInterlude()` which is itself called -from `killZombie()` mid-iteration over `zombies[]` (`zombies.js:51`). Opening a modal there -is safe (it only sets state) but do NOT make `showModal` do anything that touches the -zombie array. - -**Acceptance:** win a duel → modal with the right icon; play is frozen until tapped. - ---- - -# 9. POPUP: the horde after a won set - -**Requirement:** popup explaining you now have to shoot with the gun; blocks until tapped. - -**Trigger:** `proceedAfterClear()` sets `G.pendingHorde` and shows the SET card -(`gameplay.js:290-302`); the horde starts when the `overT` beat expires (in `update()`'s -`over` branch → `startHorde()`). - -**Build:** keep the SET card (it is the milestone beat and Oliver asked for it originally — -see the memory note on milestone cards), then show the modal instead of auto-starting: -- Title: `THEY KEEP COMING` -- Body: `THE RACKET FIRES ON ITS OWN` / `SLIDE TO AIM · HOLD THE BASELINE` -- On dismiss: `startHorde()`. - -Note `startHorde()` already pins `P.z` to the baseline and switches input to absolute -finger-steering (`touchPX()`), which is a genuinely different control mode — that is exactly -why the popup is warranted. - -**Acceptance:** win a set → SET card → modal → horde only begins after the tap. - ---- - -# 10. Start-screen logo - -**Requirement:** "ein schöneres Splatter Tennis Logo für den Startscreen generieren." - -**Cause:** the current logo is CSS text (`index.html`, `#intro .l1` / `.l2`) — a hard red -`#e0231a` over hard yellow `#eef23a` with stacked text-shadows. Generic. - -**Build:** generate a pixel-art logo PNG (Appendix A), drop it at `assets/logo.png`, replace -the two ``s with ``. Keep the `drop` animation and the blinking `TAP TO START`. -Prompt direction: bold pixel lettering, tennis-ball yellow-green and dried-blood red, a -dripping-gore underline, transparent background, roughly 640×280. - -Soften the surrounding CSS colours to match item 2 while you are in there. - -**Acceptance:** screenshot the intro at mobile and desktop; the logo must not overflow -`max-width: 92vw` and must stay crisp (`image-rendering: pixelated` on the `img`). - ---- - -# 11. The brute takes 5 hits, with more wound states - -**Requirement:** big zombie harder to kill (5 hits), correspondingly more wound states, -sprites to be generated. - -**Cause:** `config.js:80` `bruteHp: 2`. `render.js:160` has exactly one wound skin: -`if (zb.variant === 'g' && zb.hp < TUNE.bruteHp && SPR.z_gw) name = 'z_gw';` - -**Build:** -- `bruteHp: 5`. -- Generate `z_gw2`, `z_gw3`, `z_gw4` (Appendix A) as a progression: 4/5 hp = one arm torn - off (that is today's `z_gw`), 3/5 = both arms, 2/5 = jaw/half the head gone, 1/5 = barely - standing, dragging itself. Register them in `render.js`'s `SPR` preload list (line ~75). -- Select by hp: - ```js - if (zb.variant === 'g' && zb.hp < TUNE.bruteHp) - name = ['z_gw4', 'z_gw3', 'z_gw2', 'z_gw'][Math.max(0, zb.hp - 1)] || 'z_gw'; - ``` - (guard each with an `SPR[...]` existence check like the current code does, so a missing - asset degrades to the walk frame instead of drawing nothing.) -- `bleed()` (`zombies.js:158`) picks wound anchors per variant — extend the brute branch so - later stages bleed from more points. Keep it sparse; the settled feel note is explicit - that blood must stay thin (see `HANDOFF.md` and the feel memory). - -**Balance check:** the brute already soaks machine-gun pellets one per hit -(`zombies.js` pellet loop). At 5 hp it costs 5 pellets — fine during a horde, but verify a -brute arriving during normal play is still killable with the ~1.4s serve cycle before it -reaches the baseline. Measure `time-to-kill vs time-to-baseline`; if it is unwinnable, say -so rather than quietly lowering the hp Oliver asked for. - ---- - -# 12. Fire spreads - -**Requirement:** the fireball kills but **ignites** surrounding zombies; a burning zombie -that walks near another sets that one alight too; needs a dynamic fire animation. - -**Cause:** today `ball.flame` (`zombies.js:245`) is instant-kill-on-touch with full pierce, -and nothing else. The only fire visual is the ball's ember trail -(`gameplay.js` pushes `parts` with `fire: true`; drawn at `render.js:496`). - -**Build — new state on the zombie:** -```js -zb.burnT // seconds of burning left; undefined = not on fire -``` - -`config.js`: -```js -burnTime: 3.0, // s a zombie burns before it collapses -burnDps: 1.0, // hp lost per second while burning (so burnTime*burnDps must exceed hp) -burnSpread: 1.3, // m — a burning body ignites anything this close -burnIgniteR: 2.6, // m — the flame ball's initial ignition radius around the kill -``` - -`zombies.js`: -- On a flame-ball contact: kill the struck zombie as now, then ignite every zombie within - `burnIgniteR` (`zb.burnT = TUNE.burnTime`). -- In `updateZombies`'s per-zombie loop: if `burnT > 0`, decrement it, apply `burnDps * dt` - damage (kill via the existing `killZombie` path so gore/scoring/ball-boy all still fire), - emit 1–2 `fire: true` particles per frame from the body, and ignite any un-burning zombie - within `burnSpread`. -- **Chain-reaction hazard:** `killZombie` splices the array and `splatPoint` can cascade. - The existing loops guard with `if (!zb) continue;` — the ignition pass must do the same and - must iterate backwards. `HANDOFF.md` calls this out explicitly; a fire chain is the worst - case for it, so write the loop defensively and test with 15 zombies packed together. -- Burning zombies should walk **faster** briefly (panic) — a nice touch and it makes fire a - double-edged weapon. Optional; flag it to Oliver. - -`render.js` — the dynamic animation: -- Draw flames on a burning body procedurally rather than as sprites: 3–5 vertically-stretched - flame quads per zombie, offsets driven by `Math.sin(G.idleT * f + zb.walkT)` so each body - flickers on its own phase, colours `#ffc030` → `#ff8010` → dark smoke as `burnT` runs out. - This is cheaper than a sprite sheet and reads better at 4 different burn stages. -- Tint the zombie sprite toward orange while burning (`ctx.globalAlpha` + a second - additive-ish pass, or just draw the sprite then a semi-transparent orange rect clipped to - it — keep it simple at this resolution). - -**Acceptance:** a flame serve into a line of 6 packed shamblers ignites the neighbours, the -fire visibly chains outward, and all 6 die within `burnTime`. No exception thrown (this is -the loop-mutation trap). Frame time stays sane with 14 burning bodies. - ---- - -# 13. Death animation — the player becomes gore - -**Requirement:** "Wenn man gefressen wird braucht es eine Endanimation, die zeigt dass man -selbst zu gore wird." - -**Cause:** `zombies.js:229` flips `G.mode = 'eaten'` and `render.js:506` simply stops -drawing the player (`if (G.mode !== 'eaten') drawChar(P)`). There is already a small burst -(30 particles + one gore decal) at the eat site, but the summary screen appears immediately -on top of it. - -**Build:** -- `G.eatenT` already counts up from 0 (`state.js`). Use it as the animation clock. -- Phase A (0 → ~0.5s): keep drawing the player, but jerking — a violent shake offset and a - reddening tint, with the zombies crowding in. Kill the ball, freeze the score. -- Phase B (~0.5 → 1.4s): the sprite is replaced by an expanding gore burst — reuse - `killZombie`'s machinery (it is the established look): a big `gore` stain, a size-4 `piles` - entry at `P.x/P.z`, and ~90 particles with a wide `vy` spread. Add `G.shake` and one - `boomFlash` frame for the impact. -- Phase C (from ~1.6s): the existing summary fades in (`render.js:559`). Gate - `TAP TO PLAY AGAIN` on `G.eatenT > 2.2` instead of `0.7` so the animation is not skipped - by a panic tap. -- The pile stays visible under the summary scrim — that is the point: you are the biggest - gore mound on the court. - -**Acceptance:** get eaten; screenshot at eatenT ≈ 0.3 / 0.9 / 2.0 and confirm three distinct -stages. Restart still works. - ---- - -# 14. Longer changeover, and the ball boys get a mop - -**Requirement:** "changeover sollte etwas länger dauern. die balljungen, die die reinigung -machen brauchen einen mop." - -**Cause:** `config.js:33` `changeoverT: 4.5`. `drawBoy()` (`render.js:136`) uses -`x_boyrun1..4` — a plain 4-frame run cycle, no prop. - -**Build:** -- `changeoverT: 7.0`. The changeover already suppresses spawns and serving - (`zombies.js` spawn gate, `keyPressed` guard), so a longer one is a real breather. Check - it does not feel dead — if it does, have more boys cross. -- Generate `x_boymop1..4` (Appendix A): the same boy, bent over, pushing a mop, 4 frames of - a push cycle. -- `drawBoy()` picks the sheet by intent. Today `sendBoy()` is used for BOTH "fetch the dead - ball" and "mop this splat" (`state.js:95`, called from `killZombie` with the corpse - position). Add a flag: `sendBoy(tx, tz, mop = false)`, set `mop: true` from `killZombie` - and from `startChangeover`, and select `x_boymop*` vs `x_boyrun*` accordingly. The mopping - boy should also move slower — mopping is not sprinting. -- The gore-cleaning already happens in `updateZombies`'s boy pass (`d.a -= dt * 1.6`); with a - mop sprite that finally reads as *why* the stain fades. - -**Acceptance:** trigger a changeover; the boy crosses with the mop sprite, stains visibly -shrink behind him, and the breather runs 7s. - ---- - -# Appendix A — generating sprites and illustrations - -The pipeline is documented in `HANDOFF.md` under "Gotchas learned the hard way"; the short -version: - -1. Use the `generate-image` skill. It needs `OPENROUTER_API_KEY`, which is already in the - repo's gitignored `.env`. Run generation scripts with a scratchpad venv python - (system python lacks `requests`); the venv is wiped between sessions, so recreate it with - `requests` + `pillow`. -2. Ask for a **magenta background** and, for multi-frame sheets, frames in a row separated by - clear gaps. -3. Slice with the committed `assets/_slice.py`: - `python3 assets/_slice.py sheet.png out1.png out2.png ...` (add `--largest` when frames - touch and stray fragments bridge the gap). -4. Register new names in `render.js`'s `SPR` preload array (~line 71). - -Known generator quirks (all from `HANDOFF.md`, all still true): -- It sometimes ignores the magenta instruction — harmless, the key uses the corner colour. -- It often draws near-identical animation frames. Re-prompt with strongly contrasted pose - descriptions. -- Passing the existing sheet as `--input` (edit mode) is the reliable way to match style. - **Do this for every asset here** — `_sheet_brute.png` for the brute wounds, - `_sheet_boyrun.png` for the mop boy — the style must match what is already on court. - -Assets this plan needs: - -| file(s) | source sheet to match | item | -|---|---|---| -| `z_gw2`, `z_gw3`, `z_gw4` | `_sheet_brute.png` / `_sheet_gwound.png` | 11 | -| `x_boymop1..4` | `_sheet_boyrun.png` | 14 | -| `assets/logo.png` | — (new, standalone) | 10 | -| transition tutorial illustration | — (new, standalone) | 6 | - -The two popup illustrations and the logo are **not** chroma-keyed sprites — generate them -with transparent or dark backgrounds sized for their use and skip `_slice.py`. - ---- - -# Appendix B — things to check before declaring done - -- `HANDOFF.md`'s benchmarks must still hold: CPU returns 11/12 serves; 30 simulated points - with human-like tapping = 50 hits / 0 whiffs. Items 2, 3 and 11 can all disturb these. -- The settled feel decisions are recorded and must not be regressed silently: the two-stage - serve, the manual wave clear, thin dynamic blood, one-gesture-means-one-thing on touch. - If any item here would break one, flag it to Oliver instead of deciding. -- Update `HANDOFF.md` with anything new that bit you. -- Bump `main.js?v=N` in the final commit so phones get the build.