From 25db84ed87dbb7aaffb5365b759c4159b9e1de16 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Sun, 12 Jul 2026 09:54:01 +0200 Subject: [PATCH] Plan v2: match structure, enemies, weapons, interlude (phases 7-13) Co-Authored-By: Claude Fable 5 --- HANDOFF.md | 11 +- PLAN.md | 373 +++++++++++++++++++++++++++++++---------------------- 2 files changed, 224 insertions(+), 160 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 5faab29..cdaed89 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -38,10 +38,13 @@ 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. -## 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. +## NEXT UP — PLAN.md (v2) +Phases 1–6 (shot loop, fanfare, gore piles, bombs, hordes, racket zombie) are SHIPPED. +PLAN.md now holds v2 (phases 7–13): match structure (games/sets/changeovers as the +difficulty director, permadeath runs), bomb juice, weapons on the new-balls rule, +enemy roster (sprites first, Oliver approves), tennis interlude. Design decisions at +the top of PLAN.md are settled with Oliver — don't re-litigate. 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): diff --git a/PLAN.md b/PLAN.md index 7465868..bf372e3 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,194 +1,255 @@ -# PLAN — Zombie mode expansion (for implementation by a follow-up session) +# PLAN v2 — Match structure, enemies, weapons, interlude (phases 7–13) + +Phases 1–6 (shot loop, fanfare, gore piles, bombs, hordes, racket zombie) are SHIPPED — +see git log. This plan is the next arc, agreed with Oliver 2026-07-12. 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. +cache one and "always Read current file state before editing"). 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: +Ground rules (unchanged from v1): - 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. +- Mobile portrait one-handed is the primary target: tap, flick, drag. No new buttons. +- Umpire lines: record with `say -v Daniel` → afconvert → assets/audio/*.m4a (pipeline + and listen-check exactly as v1 Phase 2 did; mapping lives in audio.js). + +## Design decisions (made with Oliver — do not re-litigate) +1. **The scoreboard IS the progression.** Waves = tennis games, escalation = sets, + breathers = changeovers, weapon deliveries = the new-balls rule. All structure comes + from tennis rules — that's the joke and the design. +2. **Permadeath runs.** Eaten = the run is over. Score = games/sets survived, best run + persisted. Runs are 3–5 minutes; restart is one tap (reload, as today). +3. **Enemy roster:** runner, brute, one "weird one" (shield zombie, design checkpoint). + Sprites are approved by Oliver BEFORE any behavior code (Phase 10 is a STOP). +4. **Tennis interlude = one sudden-death point** vs the racket zombie, triggered when + the umpire "restores order". Winner-takes-all, ~15s, then chaos resumes. If playtests + love it, promoting to a full game is just the win condition — do NOT build that now. --- -## Phase 1 — Tighten the shooting loop (serve-again latency, "missing balls") +## Phase 7 — Match structure: games drive difficulty, sets, changeovers, permadeath -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. +The root fix. Today spawning is a flat drip (`zSpawnEvery` 2.6s, `zMax` 6, forever) — +that's why waves feel random, easy, and progression-free. Replace the drip with a spawn +director keyed to games won, and give the run a real end. -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). +- state.js G: add `runGames: 0` (total games finished this run, never resets), + `sets: [0, 0]`, `changeover: false`. Keep `splatT` (the speed ramp stays as-is). +- **Spawn director** (zombies.js, replaces the flat timer in updateZombies): + - `spawnEvery(g) = max(TUNE.zSpawnMin, TUNE.zSpawnEvery - g * TUNE.zSpawnStep)` + with g = G.runGames. New knobs: `zSpawnMin: 1.0`, `zSpawnStep: 0.22`. + - `zMax` becomes dynamic: `min(TUNE.zMaxCap, 3 + G.runGames)`. New knob `zMaxCap: 14`. + (Keep the TUNE.zMax knob as the game-1 floor: `max(TUNE.zMax, ...)` — no, simpler: + replace zMax semantics: `zMax` = base 3, cap knob separate. Pick ONE and comment it.) + - Game 1 of a fresh run must feel lonely: 3 shamblers, ~3s apart. Check the transition's + scripted spawns still make that true. + - **Pause spawning during `G.changeover`** — the breather must actually breathe. + Zombies already on court keep walking (still dangerous, eaten still applies). +- **Changeover** (gameplay.js splatPoint, gameOver branch): after ODD total games + (real rule: 1st, 3rd, 5th… game of a set), set `G.changeover = true`, `G.phase='over'`, + `G.overT = TUNE.changeoverT` (new knob, 4.5). Boys mop (sendBoy a couple of times), + cheer, flash 'CHANGEOVER'. On expiry (setupPoint or a flag check): `changeover=false`, + voice('time') — record: `say -v Daniel "Time."` → time.m4a. Even games keep the short + beat they have today. +- **Sets**: in splatPoint's gameOver branch, `G.runGames++`; `score.games[i] >= 6` → + set won: `G.sets[i]++`, `score.games = [0,0]`, voice('game_set_massacre'), cheer(3, .7), + and **startHorde()** — the horde MOVES from "every game after the 2nd" to SET END. + Delete the `G.splatGames` trigger (state field can go too). Horde stays the set-boss + spike; endHorde still spawns the racket zombie (unchanged). +- **Zombies win a game** (baseline crossings): flash 'GAME. THEM.', and set + `G.shameGame = true` — Phase 9 uses it to skip the next new-balls delivery. No other + penalty; losing games mostly means the player is already drowning. +- **Permadeath screen** (render.js eaten overlay, main.js): the overlay becomes the run + summary — 'GAME, SET, MASSACRE' / 'survived N games, M sets' / best run. Persist best + via `localStorage.splatterBest = JSON.stringify({games, sets})` (read lazily, guard + with try/catch — private mode throws). Tap to restart = reload, as today. +- HUD: show sets alongside games (tennis scoreboard style: SETS · GAMES · PTS). -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). +Verify (Browser pane, force splatter via umpire triple-tap… which now forces a horde — +use T to set G.mode/score directly, or transition normally): +- Fresh run: ≤ 3 zombies on court in game 1, spawn gap ≈ 3s. +- T-set `G.runGames = 8`: spawn gap ≈ 1s, court fills toward the cap. +- Win game 1 (odd) → changeover: 4.5s beat, NO spawns during it, 'Time.' plays at the end. +- Win 6 games → set on HUD, horde fires, endHorde spawns the racket zombie. +- Get eaten → summary screen shows games/sets + best; reload; best survived the reload. +- Regression: tennis mode scoring/changeover untouched (changeover logic must be + splatter-only); eaten flow benchmark updated to expect the summary screen. -## Phase 2 — Multi-kill applause + umpire appreciation +## Phase 8 — Bomb juice pass (make the explosion FELT) -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. +Pure render/audio. The bomb works; it just doesn't LAND. All in explode() + render.js. -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. +- **Screen shake**: `G.shake = {t: 0.35, mag: 6}` set in explode(). render.js: at frame + start, if shake, translate the camera by `(rand±mag, rand±mag) * (t/0.35)` px and + decay t. Restore transform after. (One translate around the whole draw — cheap.) +- **Flash frame**: `G.boomFlash = 0.08` — render fills a white overlay, alpha `t/0.08`. +- **Scorch crater**: explode() pushes a gore entry with `scorch: true` (r 1.6, a 1). + render draws scorch entries near-black/brown instead of red; boys mop them 10× slower + (`d.a -= dt * 0.16`). The court should scar for most of a run. +- **Hitstop**: 0.1 → 0.18 in explode(). +- **Umpire loses composure**: record `say -v Daniel "Order. Order, please."` → + order.m4a; voice('order', 0.6) from explode(), rate-limited like horde scoring so + bomb chains don't spam it. +- boom() louder/longer if it currently underwhelms on phone speakers — Oliver judges. -## Phase 3 — Gore piles (procedural graphics, no sprite pipeline) +Verify: force a bomb (T: `G.bombs=1`, serve into a cluster): shake visible, flash frame, +scorch stays after gore fades, 'Order' plays. Screenshot for Oliver. No transform leaks +(HUD must not shake — draw HUD after restore). -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. +## Phase 9 — Weapons ride the new-balls rule (kill-meter retired) -- 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. +Story fix: weapons aren't loot, they're DELIVERED — because tennis rules say new balls +come after 7 games. The ball boys are the armorers. (In real tennis: first change after +7 games, then every 9. Too sparse for 3–5 min runs — knobs, commented with the real rule.) -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). +- Delete the kill-meter path in zombies.js splatter() (`G.killMeter`, `TUNE.bombEvery` + gone from state/config). +- gameplay.js splatPoint gameOver branch: if `G.runGames === TUNE.ballChangeFirst` (3) or + `(G.runGames - ballChangeFirst) % TUNE.ballChangeEvery === 0` (4) → schedule a delivery + at the next changeover beat: sendBoy(), voice('new_balls', 0.5), flash 'NEW BALLS', + `G.bombs = TUNE.bombMax` (a fresh tin of three). +- **Shame rule**: if `G.shameGame` (zombies took a game since the last delivery) → skip + it: flash 'NO NEW BALLS', clear the flag. The umpire punishes sloppy play. +- HUD bomb pips already exist — unchanged. +- Phase 12 will extend the delivery ladder to new weapon types; here it's bombs only. -## Phase 4 — Bomb balls +Verify: T-run to game 3 → tin delivered at changeover, bombs = 3 on HUD; let a zombie +cross before game 7 → 'NO NEW BALLS' at that delivery, next one works. killMeter is +gone from state (T.G.killMeter === undefined). -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. +## Phase 10 — SPRITE SESSION (STOP: Oliver approves every sheet before Phase 11) -- 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. +The only asset phase. Pipeline gotchas in HANDOFF (generate-image skill, OPENROUTER_API_KEY +in .env, magenta bg, scratchpad slice.py — recreate venv, IDLE_TARGET_H=112, watch for +the generator ignoring magenta or drawing near-identical frames). -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). +Sheets to produce (match the existing zombie sheets' style/palette): +1. **Runner** — gaunt, sprinting, arms trailing. Poses: 2-frame sprint (STRONGLY + contrasted stride), idle/lurch. Visibly leaner than shamblers. +2. **Brute** — huge, slow. Oliver-pleasing absurdity: it dragged a net post along and + uses it as a club. Poses: 2-frame trudge, idle. Roughly 1.5× shambler height. +3. **Shield zombie** — carries a line judge's chair as a shield, held forward. Poses: + 2-frame walk with chair up, one 'staggered' pose with the chair dropped/lowered + (that pose IS the vulnerability window, it must read at a glance). +4. **Weapon tin** — a "new balls" ball can, matte black, small skull where the brand + goes. One still; the boy carries it on delivery runs (small, ~24px). -## Phase 5 — Horde waves + machine-gun racket +Deliverable: sliced sheets committed + one composite preview image sent to Oliver. +STOP for approval. Rejected sheets get re-prompted, not hand-fixed. -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()). +## Phase 11 — Enemy roster wired in (one new type per set) -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. +Behaviors only after sprites are approved. Introduce one type per set so each gets its +moment. Spawn mix lives in one table in zombies.js: +`const MIX = set => ...` → weights for a/b, 'f' (runner), 'g' (brute), 's' (shield); +runner from set 1 game 3, brute from set 2, shield from set 3. -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. +- **Runner ('f')**: `v ×= TUNE.runnerSpeed` (3.2). Spawns SOLO with a warning shriek — + new synth sfx `shriek()` in audio.js (style of moan(), higher/harsher). Cap: at most + 1 runner alive (`zombies.some(z => z.variant==='f')` gate). It punishes tunnel vision; + the shriek is the fairness contract. +- **Brute ('g')**: `hp: TUNE.bruteHp` (2) — reuse the racket-zombie hp pattern in the + ball-contact branch (splat-quality hit decrements, killing blow → splatter()). + `v ×= 0.55`, contact radius 0.75 (vs 0.55), immune to pushback (staggers don't move + it), pellets take 3 hits (`hp` respected in the pellet loop too — currently pellets + one-shot everything). Kill = double particles, size-2 pile. +- **Shield ('s')**: blocks balls arriving from the front (`ball.vz < 0` at contact) — + clank sound (pock variant), ball spent (`lastHit='X'`), zombie unharmed. A STAGGER + (any front hit) drops the chair for `TUNE.shieldDown` (1.5s) — during that window any + splat-quality ball kills it. Also always dies to: bombs, melee, pellets, and balls + hitting it from BEHIND (`ball.vz > 0` — the flick-down-behind-it shot, which the + free-aim system already allows). Render the dropped-chair pose during the window. +- render.js drawChar: wire the three sheets; brute scale ×1.5 off SPRITE_H. +- Eaten radius: brute 0.9 (it's bigger). Runner eaten radius unchanged (speed is enough). -## Phase 6 — Racket zombies (NOT in the first cut — design checkpoint first) +Verify (per type, T.spawnZombie grows a variant arg — it already has one): +- Runner: shriek on spawn, crosses the court ~3× faster, only ever one alive. +- Brute: survives one splat-quality hit, dies on the second, ignores pushback, eats + 3 pellets. +- Shield: front ball clanks off; stagger → chair-down pose → kill lands; a ball aimed + behind it kills outright; bomb kills it. +- Mix: T-set G.sets/runGames per set tier, watch 20 spawns, composition matches MIX. +- Full regression ladder after wiring: tennis benchmarks + phases 7–9 verifies. -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. +## Phase 12 — Weapon ladder on the delivery rail + +Extends Phase 9's deliveries beyond bombs. Every weapon stays one-thumb: it's a serve +modifier, auto-consumed, zero new inputs (exactly like ball.bomb today). + +Delivery ladder by delivery index (1st, 2nd, 3rd… of the run): +1. Bomb tin (as shipped in Phase 9) +2. **Triple serve** (`G.triples = 3` stock): the serve spawns 2 extra balls fanned ±6°. + Extra balls: check git history first — a multi-ball array existed pre-revert + (killZombie's comment still references "extra (multi-serve) balls"); reuse that + shape if salvageable, else a lightweight `extras[]` in zombies.js (ballistic clones + carrying pow, killing via killZombie, no scoring double-dip beyond it). +3. **Flame ball** (`G.flames = 3`): `ball.flame` flag — kills on ANY contact regardless + of pow (the real power: quality-free kills), full pierce (no zPierce slowdown), fire + trail (orange/yellow parts each frame, cheap), renders orange-hot. +4. **Machine gun, 8s** (`G.mgT = TUNE.mgPickupLen`): horde auto-fire machinery reused + outside hordes — firePellet + drag-to-steer for 8s, then back to serving. (Flag + interactions: serve prompt suppressed while mgT > 0, same gate as horde.) +5th+: cycle 1→4. +- HUD: one small icon row (reuse bomb pip drawing, one glyph per type). +- Flash text on delivery names the weapon ('NEW BALLS: FIRE'). +- Precedence: one special stock at a time — a delivery replaces any unspent stock + (simpler than stacking; comment it). + +Verify: T-walk the ladder (set runGames): each delivery arms the right stock, each +weapon kills as specced, mg mode enters/exits cleanly, no leakage into tennis mode. +Triple/flame respect brute hp and shield-block rules from Phase 11. + +## Phase 13 — The interlude: order is restored (one sudden-death point) + +The absurdity peak: tennis rules never stopped applying. Trigger — a racket zombie is +alive (post-horde) AND the court is otherwise clear AND no horde/changeover: the umpire +restores order and ONE real point is played. + +- Record: `say -v Daniel "Quiet, please. Play will resume."` → play_resume.m4a, and + `say -v Daniel "Sudden death."` → sudden_death.m4a. +- Trigger check in gameplay.update (splatter, ball dead, conditions above, once per + racket zombie — flag on the zombie): `G.interlude = true`, spawning paused, voices, + flash 'SUDDEN DEATH'. +- During the interlude the point is played under TENNIS rules: onBounce's line-call + branch runs (out/net = you lose the point — the umpire is watching again). You serve + (pretoss as normal); the racket zombie returns via its existing racketAI. NOTE: its + AI only chases balls with pow < zSplatQ — during the interlude it must chase ANY ball + (it's playing tennis, not dodging); killing it with a splat ball mid-interlude is + allowed and counts as winning the point (crowd goes wild). +- Resolution: your point → weapon delivery on the spot (next rung of the Phase 12 + ladder) + cheer(3, .7). Its point (return passes you, or your ball out/net) → + 'GAME. THEM.' on the scoreboard AND an instant surge: 5 shamblers spawn at once. + Either way `G.interlude = false`, normal spawning resumes. +- Zombie spectators: any shamblers that spawn-raced the trigger freeze in place for the + duration (v = 0 while G.interlude — one gate in the walk loop). They watch. +- Keep it ONE point. Full-game promotion is a possible follow-up, not this phase. + +Verify: survive a horde (or T-spawn a racket zombie + clear court), let the ball die → +interlude triggers once: voices, tennis line rules live (hit one out → THEM + surge of +5), replay → win the point → delivery fires. Racket zombie chases soft AND hard balls +during the interlude, only soft ones outside it. Eaten still works if the surge gets you. + +--- ## New TUNE knobs (summary) -splatterOverT 0.1 · zPierce 0.75 · bombEvery 8 · bombMax 3 · bombRadius 2.5 · -mgRate 6 · hordeLen 12 · hordeRate 2.5 +zSpawnMin 1.0 · zSpawnStep 0.22 · zMaxCap 14 · changeoverT 4.5 · +ballChangeFirst 3 · ballChangeEvery 4 · runnerSpeed 3.2 · bruteHp 2 · shieldDown 1.5 · +mgPickupLen 8 +Retired: bombEvery (kill meter gone). + +## Umpire lines to record (pipeline in v1 Phase 2 / audio.js) +time.m4a "Time." · order.m4a "Order. Order, please." · +play_resume.m4a "Quiet, please. Play will resume." · sudden_death.m4a "Sudden death." ## 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). +- Splatter: serve→serve ≤ 20 frames with immediate flicks; eaten flow now ends in the + RUN SUMMARY screen (games/sets/best) → tap restarts. +- Game 1 of a fresh run: ≤ 3 zombies, ~3s spawn gaps (the "starts slow" contract). - No console errors after each phase's full verify run.