From 01ee068e6dcfe5a5c7676bc3d78aca55700ddf11 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Thu, 30 Jul 2026 09:00:49 +0200 Subject: [PATCH] HANDOFF is the source of truth; record the 35-game retest findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrote HANDOFF.md around what a next session actually needs: what the game is, how it flows, the invariants you have to know before touching anything, the settled feel decisions, the regression net with its real numbers, the asset pipeline with the generator's known failure modes, and the hard-won gotchas. Five rounds of chronological "feedback round" sections are folded into that structure — the history is in the git log and the memory file, not here. Deleted POLISH-PLAN.md: all fourteen items shipped. PLAN.md is left in place but marked historical at the top of HANDOFF. Corrected two stale facts carried in the old handoff: RES is 3 (not 2, which would have thrown off the umpire-cheat coordinates) and UMP_X is 6.05 (not 5.6). Added an OPEN ISSUES section with Oliver's eight findings from the 35-game retest, each with a diagnosis from reading the code rather than just the symptom. Two of them (player frozen between games, sprite freeze after the horde) look like one root cause: the movement and pose block sits inside `if (G.phase === 'rally')`, so every 'over' beat freezes the player mid-frame while the zombies walk on. The triple-serve lob is confirmed: the wing clones integrate at flat gravity while the real serve carries up to 2.3x from topspin. Co-Authored-By: Claude Opus 5 --- HANDOFF.md | 457 ++++++++++++++++++------------------------ POLISH-PLAN.md | 529 ------------------------------------------------- 2 files changed, 198 insertions(+), 788 deletions(-) delete mode 100644 POLISH-PLAN.md 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.