Feedback round + Phase 13: the duel, free-aim, honest stakes

Oliver's six complaints, addressed:
1. Opponent never came — a racket zombie now spawns on EVERY game you win
   (if none alive); endHorde no longer double-spawns
2. No wave-cleared feeling — sweepCourt: at game end the surviving wave
   collapses, every game gets a real beat (gameBeatT 1.6 / changeover 4.5),
   and 'GAME N' announces the next wave when play re-arms
3. Serve-posture lockup during machine gun — root cause: any 'over' beat
   expiring into setupPoint() -> pretoss during auto-fire (no movement outside
   rally, no serving during horde/GUN). The over-expiry now returns auto-fire
   modes to 'rally', never the serve loop
4. Forced bomb spending — specials commit ONLY on deliberate serves: flick
   (or arrow-aimed keyboard serve) fires the tin, plain tap keeps it; serve
   prompt teaches it ('FLICK FIRES THE BOMB - TAP KEEPS IT')
5. Auto-aim — free-aim: the flick vector maps to a landing point (length =
   depth, up to 22m), zombies within zSnap 1.4 magnet the shot, otherwise the
   ball lands exactly where flicked; taps keep the old auto-target assist;
   flicks now also work at the toss apex
6. Zombies walking through you — option (a): touch is honest. eatenR 0.9
   (brute +0.2) matches the visual sprite overlap

Phase 13 — SUDDEN DEATH interlude: court clear + un-dueled racket zombie +
about to serve -> 'Quiet, please. Play will resume.' / 'Sudden death.' One
point under REAL tennis rules (line calls live, the boss plays every ball,
spawns paused). Win (line call, double bounce, or regicide by any weapon) =
the next tin delivered on the spot; lose (out, or its return passes you) =
a game on their scoreboard + an instant 5-shambler surge. One duel per
opponent. New lines: play_resume.m4a, sudden_death.m4a.

Verified in browser: horde 'over' beat -> rally (lockup dead); game win ->
sweep + boss + beat -> SUDDEN DEATH chains naturally; duel lose path (GAME.
THEM., surge 5, scoreboard), no re-duel, fresh-boss duel, bomb regicide ->
tin on the spot; tap serve keeps stock, real touch-flick consumes + lands
at the flicked point, magnet snaps when near; eaten at 0.85m; tennis 11/12;
no console errors. PLAN.md/HANDOFF updated: plan v2 complete.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-07-12 11:50:18 +02:00
1 parent 06bd24e6ea
commit 2f1419b1f2
10 files changed
+183 -285

No files matched your search

+35 -250
View File
@@ -1,255 +1,40 @@
# PLAN v2 — Match structure, enemies, weapons, interlude (phases 7–13)
# PLAN — all phases shipped (v2 complete, 2026-07-12)
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.
Phases 1–13 are implemented, verified, and deployed. See git log for the per-phase
record and HANDOFF.md for the current system map. This file now only records what
was DECIDED, so nobody re-litigates it.
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.
## The shape of the game (as shipped)
- **The scoreboard is the progression.** Waves = games (spawn director scales with
G.runGames), 6 games = a set = a horde, odd games end in changeovers, deliveries
follow the new-balls rule. Permadeath: eaten = run over, best run persisted.
- **Game-end theater**: at every game end the surviving wave collapses (sweepCourt),
a real beat plays (gameBeatT / changeoverT), and 'GAME N' announces the next wave.
- **An opponent, always**: every game you win spawns a racket zombie if none is alive.
With the court clear and an un-dueled opponent waiting, the umpire restores order:
SUDDEN DEATH — one point under real tennis line calls. Win = the next tin on the
spot; lose = a game on their scoreboard + a 5-shambler surge. One duel per opponent.
- **Weapon ladder** (deliveries cycle): BOMB → TRIPLE → FIRE → GUN. One tin at a time.
Specials fire ONLY on deliberate serves: flick = aim + commit, tap = keep the stock.
- **Free-aim**: a flick maps to a landing point (length = depth, zSnap magnetism);
taps keep the auto-target assist.
- **Roster**: runner (set 1 game 3+, shriek, solo), brute (set 2, hp 2, pushback-proof),
shield zombie (set 3, chair blocks front, stagger = kill window). Touch is honest:
eatenR 0.9 sprite-overlap = eaten.
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).
## Feedback-round decisions (Oliver, 2026-07-12)
- Zombies pass-through: resolved as (a) — touch HURTS (radius honest), not avoidance.
- Interlude frequency: currently every game won (fresh opponent each game). If it
overstays its welcome, gate it to every 2nd opponent — one-line change in the
trigger (gameplay.js, `dueled`).
- Multi-ball stays "one proper serve per fan" (triple serve), never rapid-fire.
## 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.
## Tuning surface (T panel)
All pacing knobs are in config.js TUNE — spawn director (zSpawnEvery/Min/Step, zMax,
zMaxCap), beats (changeoverT, gameBeatT), deliveries (ballChangeFirst/Every), roster
(runnerSpeed, bruteHp, shieldDown), aim (zSnap), stakes (eatenR).
---
## 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`. 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 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, 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.
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.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.
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):
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).
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 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).
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):
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)
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.
## If a next arc happens, candidates in rough order of value
1. Balance pass from phone playtests (Oliver tunes, nothing to build).
2. Boss variety: a second duel opponent type (sprite session first, as before).
3. Meta between runs: unlock/records screen beyond best-run.