Plan v2: match structure, enemies, weapons, interlude (phases 7-13)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-07-12 09:54:01 +02:00
1 parent 41a6d2a034
commit 25db84ed87
2 files changed
+224 -160

No files matched your search

+7 -4
View File
@@ -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. Ball boys mop gore they pass. HUD shows THEM instead of CPU.
- Balance knobs in TUNE (T panel): zSpeed, zSpawnEvery, zMax, zSplatQ, zPushback. - Balance knobs in TUNE (T panel): zSpeed, zSpawnEvery, zMax, zSplatQ, zPushback.
## NEXT UP — PLAN.md ## NEXT UP — PLAN.md (v2)
The zombie-mode expansion (tighter shot loop, multi-kill fanfare, gore piles, bomb balls, Phases 1–6 (shot loop, fanfare, gore piles, bombs, hordes, racket zombie) are SHIPPED.
horde waves + machine-gun racket, racket zombies later) is fully planned in PLAN.md — PLAN.md now holds v2 (phases 7–13): match structure (games/sets/changeovers as the
phased, with per-phase verify recipes. Implement it phase by phase, push after each. 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 ## 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): (serve cadence · flick-aim anywhere incl. over the net · zombie speed ramp · eaten):
+217 -156
View File
@@ -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 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 cache one and "always Read current file state before editing"). Every phase is
every phase is independently shippable: implement → verify → commit → push (push = deploy, independently shippable: implement → verify → commit → push (push = deploy, Oliver tests
Oliver tests on his phone). Do NOT start a phase before the previous one is pushed. 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 - 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). (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 - 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). 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. - 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 - Mobile portrait one-handed is the primary target: tap, flick, drag. No new buttons.
one thumb: tap, flick, drag. No new buttons. - Umpire lines: record with `say -v Daniel` → afconvert → assets/audio/*.m4a (pipeline
- Sounds: synth sfx live in audio.js; umpire lines are recorded m4a in assets/audio/ and listen-check exactly as v1 Phase 2 did; mapping lives in audio.js).
(macOS `say -v Daniel`). Generation commands are given in Phase 2.
## 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. The root fix. Today spawning is a flat drip (`zSpawnEvery` 2.6s, `zMax` 6, forever) —
1. Input swallowed: taps/flicks during `G.phase === 'over'` (even the short 0.25s splatter that's why waves feel random, easy, and progression-free. Replace the drip with a spawn
one) hit `keyPressed` and match NO branch — the tap is lost and a stored flick expires. director keyed to games won, and give the run a real end.
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): - state.js G: add `runGames: 0` (total games finished this run, never resets),
- keyPressed: in splatter mode, treat `phase === 'over'` like pretoss — first force the `sets: [0, 0]`, `changeover: false`. Keep `splatT` (the speed ramp stays as-is).
reset inline (`ball.active = false; setupPoint();`) then fall through to the existing - **Spawn director** (zombies.js, replaces the flat timer in updateZombies):
pretoss serve logic (flick → quick serve, tap → toss). The finger is now the only - `spawnEvery(g) = max(TUNE.zSpawnMin, TUNE.zSpawnEvery - g * TUNE.zSpawnStep)`
cadence limit. with g = G.runGames. New knobs: `zSpawnMin: 1.0`, `zSpawnStep: 0.22`.
- Ball physics block: in splatter mode kill spent/hopeless balls early: - `zMax` becomes dynamic: `min(TUNE.zMaxCap, 3 + G.runGames)`. New knob `zMaxCap: 14`.
`lastHit === 'X' && ball.bounces >= 1` OR out of bounds (`z < -2 || z > 25.5 || (Keep the TUNE.zMax knob as the game-1 floor: `max(TUNE.zMax, ...)` — no, simpler:
|x| > 7.5`) → `ball.active = false; G.phase = 'over'; G.overT = TUNE.splatterOverT`. replace zMax semantics: `zMax` = base 3, cap knob separate. Pick ONE and comment it.)
- New TUNE knob: `splatterOverT: 0.1` (replace the hardcoded 0.25 in the dead-ball branch). - 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): Verify (Browser pane, force splatter via umpire triple-tap… which now forces a horde —
- Flick during 'over' fires a serve the same frame (phase goes rally, ball active). use T to set G.mode/score directly, or transition normally):
- Spent ball dies within ~10 frames of its first post-stagger bounce. - Fresh run: ≤ 3 zombies on court in game 1, spawn gap ≈ 3s.
- Serve→serve cycle with immediate flicks ≤ 20 frames. - T-set `G.runGames = 8`: spawn gap ≈ 1s, court fills toward the cap.
- Tennis regression: 'over' handling in tennis mode UNCHANGED (points still pause 2.2s). - 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. Pure render/audio. The bomb works; it just doesn't LAND. All in explode() + render.js.
- 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')`), - **Screen shake**: `G.shake = {t: 0.35, mag: 6}` set in explode(). render.js: at frame
serve a perfect-quality ball down that lane (TUNE.zSplatQ low temporarily), step, expect start, if shake, translate the camera by `(rand±mag, rand±mag) * (t/0.35)` px and
kills to accumulate, flash text escalating, no crash when the ball dies mid-streak. 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 ## Phase 9 — Weapons ride the new-balls rule (kill-meter retired)
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}. Story fix: weapons aren't loot, they're DELIVERED — because tennis rules say new balls
In splatter(): if an existing pile within 0.6m → `size = min(4, size + 1)` else push come after 7 games. The ball boys are the armorers. (In real tennis: first change after
new {size: 1, seed: Math.random()*1e4}. Cap 30 piles (shift oldest). 7 games, then every 9. Too sparse for 3–5 min runs — knobs, commented with the real rule.)
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: - Delete the kill-meter path in zombies.js splatter() (`G.killMeter`, `TUNE.bombEvery`
piles visible, taller/denser where kills clustered, boys visibly shrink them but slower gone from state/config).
than they grow. Frame time sane (no per-frame allocation in drawGorePile — precompute - gameplay.js splatPoint gameOver branch: if `G.runGames === TUNE.ballChangeFirst` (3) or
blob offsets ONCE per pile on creation, store on the pile object, don't re-run the PRNG `(G.runGames - ballChangeFirst) % TUNE.ballChangeEvery === 0` (4) → schedule a delivery
every frame). 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 ## Phase 10 — SPRITE SESSION (STOP: Oliver approves every sheet before Phase 11)
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. The only asset phase. Pipeline gotchas in HANDOFF (generate-image skill, OPENROUTER_API_KEY
- zombies.js splatter(): `G.killMeter++`; at bombEvery → reset meter, and if in .env, magenta bg, scratchpad slice.py — recreate venv, IDLE_TARGET_H=112, watch for
bombs < bombMax: `G.bombs++`, sendBoy() (the delivery run), voice('new_balls', 0.2) the generator ignoring magenta or drawing near-identical frames).
+ 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 Sheets to produce (match the existing zombie sheets' style/palette):
renders black; land it amid 4 clustered zombies → all die, 'MASSACRE ×4' fires, boom 1. **Runner** — gaunt, sprinting, arms trailing. Poses: 2-frame sprint (STRONGLY
plays, big pile + stain remain. Confirm bombs don't leak into tennis mode (mode guard). 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 ## Phase 11 — Enemy roster wired in (one new type per set)
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(): Behaviors only after sprites are approved. Introduce one type per set so each gets its
- G.horde = {t: TUNE.hordeLen (12s), spawnT: 0}. voice('remain_seated', 0.3) + flash moment. Spawn mix lives in one table in zombies.js:
'THEY KEEP COMING'. (Optionally record a dedicated line "They keep coming." → `const MIX = set => ...` → weights for a/b, 'f' (runner), 'g' (brute), 's' (shield);
they_keep_coming.m4a, same pipeline as Phase 2.) runner from set 1 game 3, brute from set 2, shield from set 3.
- 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 - **Runner ('f')**: `v ×= TUNE.runnerSpeed` (3.2). Spawns SOLO with a warning shriek —
and kill, piles accumulate, score advances with rate-limited voices, horde ends cleanly new synth sfx `shriek()` in audio.js (style of moan(), higher/harsher). Cap: at most
back into serve loop, no console errors, eaten still triggers if a zombie reaches you. 1 runner alive (`zombies.some(z => z.variant==='f')` gate). It punishes tunnel vision;
Then the full ladder: tennis benchmarks + Phase 1–4 verifies still green. 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 ## Phase 12 — Weapon ladder on the delivery rail
sheet for approval. Rough shape agreed so far:
- A special zombie (`variant 'r'`, has a racket) takes up residence on the FAR side and Extends Phase 9's deliveries beyond bombs. Every weapon stays one-thumb: it's a serve
RETURNS your shots — the tennis rally skill stays alive inside zombie mode. modifier, auto-consumed, zero new inputs (exactly like ball.bomb today).
- 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 Delivery ladder by delivery index (1st, 2nd, 3rd… of the run):
slimmed copy of the tennis-mode AI block for it (slower aiSpeed ~2.8, quality capped 1. Bomb tin (as shipped in Phase 9)
~0.55, flub ~0.2, shambling run). Your non-splat-quality shots it can return; a 2. **Triple serve** (`G.triples = 3` stock): the serve spawns 2 extra balls fanned ±6°.
splat-quality ball that reaches it kills it (existing contact path). Extra balls: check git history first — a multi-ball array existed pre-revert
- Its returns are live balls: if one gets past you → splatPoint('O') via the existing (killZombie's comment still references "extra (multi-serve) balls"); reuse that
baseline rule; you can also RALLY it back (doHit already works in splatter for the shape if salvageable, else a lightweight `extras[]` in zombies.js (ballistic clones
player — zombieTarget will need a "prefer the racket zombie when rallying" tweak). carrying pow, killing via killZombie, no scoring double-dip beyond it).
- Sprite: the ONLY sprite-pipeline task in this plan (generate-image skill + scratchpad 3. **Flame ball** (`G.flames = 3`): `ball.flame` flag — kills on ANY contact regardless
slice.py — the full pipeline gotchas are in HANDOFF). Idle/run/swing poses, magenta bg. of pow (the real power: quality-free kills), full pierce (no zPierce slowdown), fire
- Spawn: one per horde survived, or one per N games — decide with Oliver. 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) ## New TUNE knobs (summary)
splatterOverT 0.1 · zPierce 0.75 · bombEvery 8 · bombMax 3 · bombRadius 2.5 · zSpawnMin 1.0 · zSpawnStep 0.22 · zMaxCap 14 · changeoverT 4.5 ·
mgRate 6 · hordeLen 12 · hordeRate 2.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) ## 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; - Tennis: CPU returns ~11/12 serves; human-like tap sim = all hits, no whiffs;
feet-bounce tap sweep ≈ 19/30. feet-bounce tap sweep ≈ 19/30.
- Splatter: serve→serve ≤ 20 frames with immediate flicks (Phase 1 onward); - Splatter: serve→serve ≤ 20 frames with immediate flicks; eaten flow now ends in the
eaten flow (zombie at <0.7m → EATEN overlay → tap restarts). 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. - No console errors after each phase's full verify run.