16 KiB
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"). 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 (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: 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)
- 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.
- 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).
- 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).
- 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 7 — Match structure: games drive difficulty, sets, changeovers, permadeath
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.
- state.js G: add
runGames: 0(total games finished this run, never resets),sets: [0, 0],changeover: false. KeepsplatT(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.zMaxbecomes dynamic:min(TUNE.zMaxCap, 3 + G.runGames). New knobzMaxCap: 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 theG.splatGamestrigger (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 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 8 — Bomb juice pass (make the explosion FELT)
Pure render/audio. The bomb works; it just doesn't LAND. All in explode() + render.js.
- 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, alphat/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.
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).
Phase 9 — Weapons ride the new-balls rule (kill-meter retired)
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.)
- Delete the kill-meter path in zombies.js splatter() (
G.killMeter,TUNE.bombEverygone 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.
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).
Phase 10 — SPRITE SESSION (STOP: Oliver approves every sheet before Phase 11)
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).
Sheets to produce (match the existing zombie sheets' style/palette):
- Runner — gaunt, sprinting, arms trailing. Poses: 2-frame sprint (STRONGLY contrasted stride), idle/lurch. Visibly leaner than shamblers.
- 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.
- 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).
- 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).
Deliverable: sliced sheets committed + one composite preview image sent to Oliver. STOP for approval. Rejected sheets get re-prompted, not hand-fixed.
Phase 11 — Enemy roster wired in (one new type per set)
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.
- Runner ('f'):
v ×= TUNE.runnerSpeed(3.2). Spawns SOLO with a warning shriek — new synth sfxshriek()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 (hprespected 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 < 0at contact) — clank sound (pock variant), ball spent (lastHit='X'), zombie unharmed. A STAGGER (any front hit) drops the chair forTUNE.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).
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.
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):
- Bomb tin (as shipped in Phase 9)
- Triple serve (
G.triples = 3stock): 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 lightweightextras[]in zombies.js (ballistic clones carrying pow, killing via killZombie, no scoring double-dip beyond it). - Flame ball (
G.flames = 3):ball.flameflag — 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. - 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, andsay -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)
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; 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.