From fe5532b558e36a48cb638ffa5d3492088798cf95 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Sat, 11 Jul 2026 21:37:04 +0200 Subject: [PATCH] PLAN.md: phased zombie-mode expansion plan for follow-up implementation Five phases, each independently shippable with verify recipes: tighter shooting loop, multi-kill umpire fanfare, procedural gore piles, bomb balls delivered by ball boys, horde waves with machine-gun racket. Racket zombies deferred to a design checkpoint (Phase 6, not first cut). Co-Authored-By: Claude Fable 5 --- HANDOFF.md | 7 +- PLAN.md | 194 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 PLAN.md diff --git a/HANDOFF.md b/HANDOFF.md index 0d570aa..5faab29 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -38,7 +38,12 @@ Two runtime-only import cycles (documented in headers): input↔gameplay, zombie Ball boys mop gore they pass. HUD shows THEM instead of CPU. - Balance knobs in TUNE (T panel): zSpeed, zSpawnEvery, zMax, zSplatQ, zPushback. -## ACTIVE TASK — all four items from Oliver's feedback round are DONE and VERIFIED +## NEXT UP — PLAN.md +The zombie-mode expansion (tighter shot loop, multi-kill fanfare, gore piles, bomb balls, +horde waves + machine-gun racket, racket zombies later) is fully planned in PLAN.md — +phased, with per-phase verify recipes. Implement it phase by phase, push after each. + +## 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; diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..7465868 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,194 @@ +# PLAN — Zombie mode expansion (for implementation by a follow-up session) + +Read HANDOFF.md FIRST (module map, verify recipes, gotchas — especially the stale-module +cache one and "always Read current file state before editing"). This plan is ordered so +every phase is independently shippable: implement → verify → commit → push (push = deploy, +Oliver tests on his phone). Do NOT start a phase before the previous one is pushed. + +Ground rules for the implementer: +- Zero dependencies, no build step, vanilla ES modules. Match the existing code style + (compact, commented for intent, TUNE knobs for anything Oliver might want to tune). +- The tennis benchmarks in HANDOFF ("Tennis feel — CONFIRMED GOOD") MUST keep holding + after every phase. Run them via window.T (Browser pane, RAF paused, T.step manual). +- New TUNE knobs go in config.js TUNE so they appear in the live T panel. +- Mobile portrait one-handed is the primary target. Every new interaction must work with + one thumb: tap, flick, drag. No new buttons. +- Sounds: synth sfx live in audio.js; umpire lines are recorded m4a in assets/audio/ + (macOS `say -v Daniel`). Generation commands are given in Phase 2. + +--- + +## Phase 1 — Tighten the shooting loop (serve-again latency, "missing balls") + +Problem (diagnosed): two independent causes. +1. Input swallowed: taps/flicks during `G.phase === 'over'` (even the short 0.25s splatter + one) hit `keyPressed` and match NO branch — the tap is lost and a stored flick expires. + The player feels "I flicked and nothing came". +2. Spent balls die slowly: a staggered ball (`lastHit === 'X'`) or a shot that missed + everything stays alive until it physically rolls out (|vy| < 0.8 after bounces), which + can take seconds when it flies long. Only then does the 0.25s over-timer even start. + +Changes (gameplay.js unless noted): +- keyPressed: in splatter mode, treat `phase === 'over'` like pretoss — first force the + reset inline (`ball.active = false; setupPoint();`) then fall through to the existing + pretoss serve logic (flick → quick serve, tap → toss). The finger is now the only + cadence limit. +- Ball physics block: in splatter mode kill spent/hopeless balls early: + `lastHit === 'X' && ball.bounces >= 1` OR out of bounds (`z < -2 || z > 25.5 || + |x| > 7.5`) → `ball.active = false; G.phase = 'over'; G.overT = TUNE.splatterOverT`. +- New TUNE knob: `splatterOverT: 0.1` (replace the hardcoded 0.25 in the dead-ball branch). + +Verify (Browser pane, force splatter via 3 synthetic umpire taps — see HANDOFF): +- Flick during 'over' fires a serve the same frame (phase goes rally, ball active). +- Spent ball dies within ~10 frames of its first post-stagger bounce. +- Serve→serve cycle with immediate flicks ≤ 20 frames. +- Tennis regression: 'over' handling in tennis mode UNCHANGED (points still pause 2.2s). + +## Phase 2 — Multi-kill applause + umpire appreciation + +Mechanic: one ball (or bomb, Phase 4) that splatters ≥ 2 zombies gets escalating fanfare. +- state.js ball: add `kills: 0`. Reset to 0 in doHit and hitServe (any new shot). +- zombies.js splatter(): `ball.kills++`. After the existing splatPoint call: + kills === 2 → `G.flash = {text:'DOUBLE KILL', t:0.9}; cheer(1.6, 0.45); voice('double_kill', 0.4)` + kills === 3 → 'TRIPLE KILL', cheer(2.2, 0.6), voice('triple_kill', 0.4) + kills >= 4 → `MASSACRE ×${kills}`, cheer(2.8, 0.7), voice('unbelievable', 0.4) + (cheer/voice are imported from gameplay/audio already; check zombies.js imports.) +- Make natural pierce-doubles possible: splatter() currently halves pace + (`ball.vx *= 0.5; ball.vz *= 0.5`). Change 0.5 → `TUNE.zPierce` (new knob, 0.75), so a + hot ball can plow through a lined-up pair. +- New umpire lines (record on this Mac, same voice as existing files — inspect audio.js + first for the exact name→file mapping): + say -v Daniel "Double kill. Quiet, please." -o /tmp/dk.aiff && afconvert -f m4af -d aac /tmp/dk.aiff assets/audio/double_kill.m4a + say -v Daniel "Triple kill. Remarkable." -o /tmp/tk.aiff && afconvert -f m4af -d aac /tmp/tk.aiff assets/audio/triple_kill.m4a + say -v Daniel "Unbelievable." -o /tmp/ub.aiff && afconvert -f m4af -d aac /tmp/ub.aiff assets/audio/unbelievable.m4a + Listen-check the files (afplay) before committing. + +Verify: T-script — line up 3 zombies on one lane (`T.spawnZombie(0, 8/10/12, 'a')`), +serve a perfect-quality ball down that lane (TUNE.zSplatQ low temporarily), step, expect +kills to accumulate, flash text escalating, no crash when the ball dies mid-streak. + +## Phase 3 — Gore piles (procedural graphics, no sprite pipeline) + +Goal: the court gets progressively more disgusting. Stains (existing) + NEW 3D-ish piles +that grow when kills happen near each other. Ball boys shave them slowly but net growth +wins over a long match. + +- zombies.js: new export `piles = []` — {x, z, size (1..4 float), seed}. + In splatter(): if an existing pile within 0.6m → `size = min(4, size + 1)` else push + new {size: 1, seed: Math.random()*1e4}. Cap 30 piles (shift oldest). + Boy mopping loop: overlapping pile → `size -= dt * 0.25` (slower than stain mopping); + remove pile at size <= 0.3. Keep stains as-is (they're the ground layer). +- render.js: `drawGorePile(p)` — procedural pixel-art, deterministic from p.seed so piles + don't shimmer frame to frame. Algorithm: seeded PRNG (inline mulberry32 is fine); + N = 6 + size*5 blobs; each blob an ellipse at radius ≤ size*0.28m around (x,z), with a + vertical lift up to size*0.14m (projected: `y` offset via proj scale) so the pile has + HEIGHT, not just a stain; blob radii 1–3px * proj scale; colors weighted: + '#6e1414' '#8c1212' '#4e0e0e' '#7a2a18' + '#cfc9b0' (bone flecks, ~10%). + Draw order: with the gore stains (after court, before characters), split far/near of + the net like zombies are (a pile at z > netZ must occlude correctly). Sort by z. +- More particles per kill: 14 → 24 in splatter(), and give ~1/3 of them longer life + (t up to 1.2) with darker color — check render's parts loop for where color is set. + +Verify: force splatter, TUNE.zSplatQ = 0, kill ~10 zombies in one area, screenshot: +piles visible, taller/denser where kills clustered, boys visibly shrink them but slower +than they grow. Frame time sane (no per-frame allocation in drawGorePile — precompute +blob offsets ONCE per pile on creation, store on the pile object, don't re-run the PRNG +every frame). + +## Phase 4 — Bomb balls + +Design (decided, but flag to Oliver if it plays badly): ball boys deliver a black bomb +ball as a REWARD every `TUNE.bombEvery = 8` splatter kills. It's a stock (max +`TUNE.bombMax = 3`), shown on the HUD, and the NEXT serve automatically consumes one +(no extra input — one-handed rule). Alternative delivery ideas Oliver floated: umpire +throws it, or court pickup — do NOT build those, just note them in the commit message. + +- state.js: G.bombs = 0, G.killMeter = 0. +- zombies.js splatter(): `G.killMeter++`; at bombEvery → reset meter, and if + bombs < bombMax: `G.bombs++`, sendBoy() (the delivery run), voice('new_balls', 0.2) + + flash 'BOMB BALL'. +- gameplay.js hitServe: in splatter mode, if `G.bombs > 0` → `G.bombs--`, + `ball.bomb = true` (cleared on every other serve/hit). +- zombies.js: `explode(x, z)` — all zombies with `hypot(zx-x, zz-z) < TUNE.bombRadius` + (2.5) splatter (iterate a COPY or walk the array backwards — splatter() splices; each + kill still increments ball.kills so Phase 2 fanfare fires naturally); one big stain + (r ~1.4), a pile of size 3, 40 particles, `G.hitstop = 0.1`, boom sound. + Trigger: in the zombie-contact check, `ball.bomb` → explode at ball pos (regardless of + pow), ball.active = false. Also in gameplay's bounce handling: splatter && ball.bomb && + first bounce → explode there. +- audio.js: `boom()` — synth: ~80Hz sine with fast pitch drop + white-noise burst, + 0.4s, follow the style of splat()/pock(). +- render.js: bomb ball drawn black with a 1px flickering orange fuse pixel on top + (drawBall gets a variant flag or check ball.bomb where the match ball is drawn). + HUD: when G.bombs > 0, draw that many small black circles right of the YOU score line. + +Verify: T-script — set killMeter to 7, kill one → bomb appears on HUD; serve → ball +renders black; land it amid 4 clustered zombies → all die, 'MASSACRE ×4' fires, boom +plays, big pile + stain remain. Confirm bombs don't leak into tennis mode (mode guard). + +## Phase 5 — Horde waves + machine-gun racket + +Trigger (decided): first horde when the player wins his 2nd splatter GAME, then every +game won after. Plus the test/fun cheat: triple-tap the umpire while already in splatter +mode → force a horde (main.js umpire listener currently calls forceZombies() which +no-ops outside tennis — extend it: in splatter, call startHorde()). + +Flow: splatPoint's gameOver branch (splatter) → startHorde(): +- G.horde = {t: TUNE.hordeLen (12s), spawnT: 0}. voice('remain_seated', 0.3) + flash + 'THEY KEEP COMING'. (Optionally record a dedicated line "They keep coming." → + they_keep_coming.m4a, same pipeline as Phase 2.) +- During horde (updateZombies): ignore zMax; spawn every 1/TUNE.hordeRate (2.5/s), + from the far baseline AND the sides (x = ±6, z random 0..8). Zombie speed ramp applies. +- Machine gun: while horde active the racket AUTO-FIRES — no taps needed. The thumb + DRAG steers the player left/right (existing touchAxis movement; disable auto-assist + during horde). Pellets fire straight up-court (-z) with ±4° random spread, every + 1/TUNE.mgRate s (6/s). +- Pellets are NOT the match ball — new lightweight array in zombies.js + `pellets = []` {x, y, z, vx, vz, t}: flat trajectory (no gravity, y fixed ~0.9), + speed ~26 m/s, t = 1.2s despawn, kill on zombie contact d < 0.55 (always splatter- + quality: gore + pile + splatPoint). Update them in updateZombies, render as 2×2 yellow + rects with a tiny ground shadow, drawn with the ball layer. +- Umpire spam control: splatPoint gains an optional `quiet` param — during horde, + point voices are rate-limited (only speak if > 2s since last score line; game-win + lines always speak). Keep the score ticking on the HUD regardless. +- Horde end (t ≤ 0): stop auto-fire, existing serve loop resumes (setupPoint), + voice('quiet_dark', 0.5, 0.85). Any zombies still alive stay alive — the player + mops up by serving. Eaten rule stays ON during hordes (swarms are lethal). +- Match ball during horde: kill it (ball.active = false) at horde start; serve prompt + suppressed while G.horde (render gate, same spot as the eaten gate). +- Firing sound: rapid low-vol pock(0.25, 1400) per pellet is fine — verify it doesn't + clip; if it does, only sound every 2nd pellet. + +Verify: force horde via umpire triple-tap; step 12s: spawn count ≈ 30, pellets stream +and kill, piles accumulate, score advances with rate-limited voices, horde ends cleanly +back into serve loop, no console errors, eaten still triggers if a zombie reaches you. +Then the full ladder: tennis benchmarks + Phase 1–4 verifies still green. + +## Phase 6 — Racket zombies (NOT in the first cut — design checkpoint first) + +STOP: before implementing, show Oliver a one-paragraph design + the generated sprite +sheet for approval. Rough shape agreed so far: +- A special zombie (`variant 'r'`, has a racket) takes up residence on the FAR side and + RETURNS your shots — the tennis rally skill stays alive inside zombie mode. +- Reuse the existing CPU machinery, don't rebuild it: give the racket zombie the same + shape trySwing/doHit need (side:'O', x, z, facing, pose, poseT, swingT) and run a + slimmed copy of the tennis-mode AI block for it (slower aiSpeed ~2.8, quality capped + ~0.55, flub ~0.2, shambling run). Your non-splat-quality shots it can return; a + splat-quality ball that reaches it kills it (existing contact path). +- Its returns are live balls: if one gets past you → splatPoint('O') via the existing + baseline rule; you can also RALLY it back (doHit already works in splatter for the + player — zombieTarget will need a "prefer the racket zombie when rallying" tweak). +- Sprite: the ONLY sprite-pipeline task in this plan (generate-image skill + scratchpad + slice.py — the full pipeline gotchas are in HANDOFF). Idle/run/swing poses, magenta bg. +- Spawn: one per horde survived, or one per N games — decide with Oliver. + +## New TUNE knobs (summary) +splatterOverT 0.1 · zPierce 0.75 · bombEvery 8 · bombMax 3 · bombRadius 2.5 · +mgRate 6 · hordeLen 12 · hordeRate 2.5 + +## Benchmarks that must stay green after EVERY phase (scripts in HANDOFF) +- Tennis: CPU returns ~11/12 serves; human-like tap sim = all hits, no whiffs; + feet-bounce tap sweep ≈ 19/30. +- Splatter: serve→serve ≤ 20 frames with immediate flicks (Phase 1 onward); + eaten flow (zombie at <0.7m → EATEN overlay → tap restarts). +- No console errors after each phase's full verify run.