Files
splatter-tennis/PLAN.md
T
wuwiandClaude Fable 5 fe5532b558 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 <noreply@anthropic.com>
2026-07-11 21:37:04 +02:00

195 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.