Files
splatter-tennis/HANDOFF.md
T
wuwiandClaude Opus 5 ffac9b5512 Make every weapon hurt the boss, drop his HP bar, un-cap the director
Steps 1-3 of the late-game depth plan, after Oliver's 50+ game playtest.

"Sometimes the racket zombie doesn't seem harmable" was real, not a feeling.
Three of the five rack slots did nothing at all to him and said nothing about
it: TRIPLE side balls skipped him, GUN and horde pellets skipped him, fire
couldn't ignite him. BOMB meanwhile deleted him outright ignoring his hp. All
of it now routes through one wound() with a TUNE.dmg* table, so damage is
proportional to the body and a future 20 hp boss needs no new code. Fractional
damage banks on zb.chip so whole points only come off hp — the wound skins
switch on hp thresholds. His contact radius goes to 0.7 m, because part of
"unharmable" was just missing him. Melee still skips him and that's correct:
he's on the far side of the net and melee can't span it.

The HP bar is gone. It was the only floating UI on a body, it was on screen
for nearly the whole run, and nobody knew what it meant — while the wound
skins already say the same thing in the language every other multi-hit enemy
uses. A one-per-run modal explains what he is instead. Skins and bleed anchors
now read a fraction of hpMax, since his hp scales with the run.

And the plateau: every difficulty dial bottomed out by about game 12, so games
12-50 were arithmetically identical. Body cap, spawn gap, shamble speed,
variant mix, boss hp and horde size all keep climbing now, calibrated to track
the old curve through game 12 rather than replace it. One deliberate exception:
shamble speed was time-based and maxed during game 2, so the early game is
genuinely gentler now — zSpeedPerGame if that reads flabby.

HANDOFF carries the full spec for steps 4 and 5 (ranged attacks with player
damage, then chapter bosses) including the decisions we settled, so the next
session can pick it up cold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 12:30:27 +02:00

454 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# HANDOFF — Splatter Tennis
**This file is the only planning document.** `PLAN.md` and `POLISH-PLAN.md` are deleted —
everything they specified is shipped, and what still matters from them is recorded below. For the
long history, `~/.claude/projects/.../memory/splatter-tennis-history.md` is auto-loaded; for the
detail of any individual change, read the git log.
Last updated 2026-07-30, mid-way through the **late-game depth work** after Oliver's 50+ game
playtest. Steps 1-3 of that plan are DONE; **steps 4 and 5 are the next job and are specified in
full at the bottom of this file** — start there. Everything from the earlier retest rounds is closed
and recorded below it.
## What this is
SNES-style pseudo-3D tennis (Super Tennis reference) that transitions into a zombie splatter game
while keeping every tennis aesthetic (umpire voice, 15/30/40 scoring, ball boys, applause).
Zero-dependency vanilla JS, native ES modules, no build step. Mobile portrait one-handed play is
the primary target.
- **Run locally**: `preview_start` name `splatter-tennis` (python http.server :8641, `.claude/launch.json`)
- **Deploy**: push to `git@gitea.wuwi.one:wuwi/splatter-tennis.git` (main) → Coolify webhook builds
the Dockerfile (nginx, no-cache). Oliver tests on his phone at splatter-tennis.wuwi.one ~30s after
a push. **Bump `main.js?v=N` in index.html every time** or phones keep the old build.
The Dockerfile must also COPY any new top-level file (it currently ships `index.html`,
`manifest.webmanifest`, `*.js`, `assets/`).
- **Oliver edits files himself** (tuning values, sometimes sprites). ALWAYS Read current file state
before editing; never assume your last version is what is on disk.
## Module map (all in repo root)
`config.js` TUNE (live T panel) + structural constants + `UI` palette · `state.js` the `G` object +
P/O/ball/score + `setupPoint`/`sendBoy` · `gameplay.js` ballistics/swings/serve/rules/AI/`update()` +
transition + `splatPoint` + ball-boy routines · `zombies.js` zombie/gore/particle state +
`updateZombies` · `render.js` camera/palette-lerp/sprites/all drawing · `input.js` keys/touch/flick ·
`audio.js` sfx + recorded umpire (`assets/audio/*`, macOS `say -v Daniel`) · `panel.js` the T panel ·
`main.js` boot, loop, umpire-tap cheat, `window.T` test surface.
Two runtime-only import cycles (documented in headers): input↔gameplay, zombies↔gameplay.
## How the game flows
Tennis → win a game → **transition** (`G.mode='transition'`, timeline on `G.transT`) → **splatter**.
In splatter you serve at zombies; `ball.pow` ≥ `TUNE.zSplatQ` splatters, below that it staggers.
Tennis scoring keeps running. `TUNE.killsPerPoint` kills = one point; games/sets accumulate;
6 games = a set = a **horde** (racket auto-fires, finger steers you absolutely).
Odd games end in a **changeover** breather. A won game leaves the wave standing — you must
**clear it by hand** (`G.clearing`) before play advances (`proceedAfterClear`).
Weapons are **only ever won**, in the **sudden-death duel** (`G.interlude`) against the racket
zombie. Getting caught = `G.mode='eaten'` = permadeath, and the run summary is the whole game.
## Architecture & invariants — know these before touching anything
- **`G.modal` is the only thing that stops the world.** `update()` early-returns on it (after
flash/card decay), so no zombie steps, no ball flight, no spawn timers — only `idleT` ticks for
the blink. `showModal`/`dismissModal` in gameplay.js; `drawModal` is the LAST thing render draws.
Dismissal must **swallow the gesture**: input.js `touchstart` returns on it, keydown returns on
it, and the `pointerdown` dismisser is registered **only on `pointer: fine`** — on touch,
pointerdown fires *before* touchstart, so a shared handler cleared the modal and let the
touchstart underneath serve. `G.seen.<key>` gates first-time-only modals.
**`m.in` (0..1 over `TUNE.modalIn`) is the arcade entrance** and the ONE thing that animates behind
the freeze — it is ticked inside that same early-return, so it advances while nothing else does.
Icon and title fly in from the right (with a two-ghost speed streak), body lines cascade from the
left, all on `easeOutBack`; every x is `Math.round`ed, because a fractional x on a nearest-neighbour
scaled image re-snaps its pixel columns (the boiling that once read as HUD flicker). A modal with no
`in` field draws fully landed, so older/test call sites still work.
While `m.in < 1`, `dismissModal` **returns true without dismissing** — it swallows the gesture but
won't let a panic tap that was already mid-air skip a popup nobody has read. Returning false there
would drop that tap through into a serve instead.
- **The difficulty director lives in `updateZombies`'s spawn block + `spawnZombie` + `pickVariant`,
and every dial is keyed on `G.runGames`.** It is the thing that broke first: as shipped, ALL of it
bottomed out by about game 12 (body cap pinned at 14, spawn gap floored at 1.0 s, variant mix frozen
at exactly 0.42 advanced, shamble speed maxed after 105 *seconds* of splatter, boss stuck at 3 hp,
every horde identical). Oliver played 50+ games and reported the plateau; games 12-50 were
arithmetically the same game. All six now keep climbing — see the TUNE block marked
THE DIFFICULTY DIRECTOR. Calibration rule: the curve must **track the old values through ~game 12**
and only then extend, or you have silently nerfed the part of the game that was already fine.
The raw dials do run out around game 25 by design; bosses and ranged attacks (steps 4-5) are what
carry escalation past that, not more bodies.
- **`wound(zb, amount, fx)` in zombies.js is the ONE place damage is applied to a multi-hit body.**
Fractional amounts are legal (a pellet chips `TUNE.dmgPellet` = 0.34 of a point); they bank on
`zb.chip` and only WHOLE points come off `zb.hp`, because the wound skins and `bleed()` anchors
switch on hp thresholds and a fractional hp would never match one. It returns true when the body
should die and the CALLER kills it — `splatter()` when the kill should get the multi-kill fanfare,
`killZombie()` for a quiet one — after which the caller must stop touching `zb`, because
`killZombie` splices the array. A boss with 20 hp therefore needs no new damage code at all.
- **Boss damage state is read as a FRACTION of `zb.hpMax`, never against literal hp numbers.**
`spawnRacketZombie` scales hp with `TUNE.racketHpPerGame` and stores `hpMax`. Two places switch on
it and they MUST stay in step: `drawZombie`'s skins (`<= 0.34` headless `z_cw2`, `<= 0.67` torn-arm
`z_cw1`) and `bleed()`'s wound anchors. The old hardcoded `hp === 2` left a scaled boss looking
pristine until the killing blow.
- **The racket boss has NO health bar, deliberately.** It was the only floating UI on a body anywhere,
it was on screen for nearly the whole run (a boss respawns every game you win), and players did not
know what it meant. The wound skins already encode the same information in the language every other
multi-hit enemy uses. A one-per-run modal (`G.seen.racket`, fired from `spawnRacketZombie`) explains
what he *is* instead. Do not re-add a bar; give a new boss more wound stages.
- **`UI` in config.js is the accent palette** (hot/warm/pick/cream/dim/bad/ball). Every HUD colour
goes through it. Gore reds are deliberately NOT in it — the splatter stays loud.
- **`F(size, bold)` in render.js builds every canvas font string.** Bare `monospace` resolves to
Courier, a slab SERIF — that was the "generic serif" bug, not taste. Never hand-write `ctx.font`.
Widest string checked fits 270px portrait with ~10px spare.
- **Weapons are only ever WON.** `deliverTin()` has exactly one caller: `resolveInterlude('P')`.
The schedule (`TUNE.duelFirst`/`duelEvery`, `G.duelDue`) calls the **duel**, fired from
`proceedAfterClear()` — the one beat where the court is guaranteed empty. The game beat it
interrupts is parked in `G.afterDuel` and resumed when the duel resolves.
- **Anything that blocks serving.** If a "can't serve" report ever comes in, check these first —
each is an early return in `keyPressed`: `G.mgT`, `G.horde`, `G.changeover`, `G.interlude.introT`,
`G.modal`. `G.mgT` in particular must keep counting down in EVERY phase (it used to tick only
during `rally`, so a duel calling `setupPoint()` stranded it above zero forever = permanent lock).
- **Animation frames step off DISTANCE COVERED, never elapsed time** — `boy.dist` for ball boys,
`zb.z / TUNE.bruteStride` for brutes. Time-driven cycles moonwalk the moment speed and frame rate
disagree. Sprite heights follow the PNG (`img.height / SPRITE_H`) because `_slice.py`
common-scales a sheet; a wound stage's height must ALWAYS come from its A frame or it pops.
- **Fire chains.** `zb.burnT`/`burnD`; `ignite()` is read-only over `zombies[]` so it is safe inside
a kill loop; `burnTick()` returns true when the fire killed the body and the caller MUST
`continue`. The flame ball also ignites what it FLIES PAST (`TUNE.burnBallR`) — without that its
full pierce killed its own lane outright and nothing was left to catch. Flames are drawn
procedurally (`drawFlames`), no sprite sheet.
- **Any loop over `zombies[]` whose body can kill** can cascade into a sweep and gut the array
mid-iteration — keep the `if (!zb) continue` guards and iterate backwards.
- **The ball boy is ONE slot.** `sendBoy` never replaces a boy already on court (a kill streak used
to teleport him back to the wing several times a second); new work is appended via `jobs`.
`mopRound` walks him to a stain, holds him there for `TUNE.boyMopDwell` while the push cycle
animates in place, then moves him on. Three sheets: `x_boymop` (stopped, working),
`x_boycarry` (walking with the mop shouldered), `x_boyrun` (tennis-mode fetch).
- **Crowd applause is a real two-frame swap** (`x_crowd` seated / `x_crowd2` arms up), per column on
its own phase, with **no vertical offset**. A fractional `dy` on a scaled image re-snaps its pixel
rows every frame under nearest-neighbour — that boiling beside the HUD was the "scoreboard
flicker" (the board art is 99.9% opaque; nothing was ever showing through it).
**If you touch one crowd frame you must apply the identical curve to the other**, or the swap
starts flashing brightness. `assets/_sheet_crowd1.png` is the pre-brightening original.
## Settled feel decisions — do not regress silently
- **Two-stage serve**: touch = reticle, drag = aim, release = fire, drag back under `pullMin` =
cancel. The pull's DIRECTION picks the lane, its LENGTH is power — deliberately decoupled so a
far shambler never costs a longer drag than the screen has.
- **The manual wave clear** after a won game (`G.clearing`). Oliver was emphatic.
- **Thin, dynamic blood.** Open wounds gush live particles (`bleed()`); blood is never painted into
a sprite. Gore piles/stains are the loud part; keep the trickle sparse.
- **One gesture means one thing** on touch. No mode where a drag silently becomes something else.
- **Volleys**: pre-bounce contact gets reach ×1.4 / window ×1.8 (`VOLLEY`). A RISING ball is
hittable at any height; only a dropping ball below `CONTACT.minY` is past you.
- **Aim magnet**: `flickTarget` picks the best-ALIGNED body, with `TUNE.aimTie` of slack in which a
nearer one still wins, so a straight-ahead lane still eats the nearest body first.
`aimTie: 1` restores the old "nearest along the lane always wins" for A/B.
- `TUNE.fps` 60 — 30 was vetoed on feel.
- **The player only walks during `rally`.** Never between points. Letting him walk during the 'over'
beats and `pretoss` was shipped once and vetoed immediately: *"I basically can never walk around
freely"* — so suddenly being able to, only between points, reads as the game breaking rather than
as a fix. Frozen-with-danger-approaching is fixed by holding the DEAD instead (`held` in
`updateZombies`), never by handing back movement.
- **The player must always be ON SCREEN.** `P_BOUNDS.x` is 6.5 m but portrait only shows about
±4 m either side of the centre line at the baseline, so the walk bound alone let him leave the
frame entirely. `xLim = Math.max(xVis, COURT.W / 2)` — the camera limit, floored at the sideline so
every legal ball stays catchable.
## Verification recipe (the regression net)
Drive the game deterministically via `window.T` with `javascript_tool`. The Browser-pane eval
context reports `document.visibilityState === 'hidden'`, so RAF is paused; halt it anyway with
`window.requestAnimationFrame = () => 0` and step with `T.step(1/60)`. `T.press('Space')` = tap.
Clear the intro first: `document.getElementById('intro')?.remove()`.
- **You MUST set `T.TUNE.autoAssist = 1.15` first** — the Browser pane reports `pointer: fine`, so
the mobile assist is off and every ball reads as "never reachable".
- Force splatter with `T.forceZombies()` then ~600 steps (in splatter it forces a HORDE instead).
- `T.zombies`, `T.spawnZombie(x, z, 'a')`, `T.TUNE.zMax = 0` to stop spawns during tests.
- Also exposed: `T.explode`, `T.splatPoint`, `T.startDuel`, `T.resolveInterlude`, `T.deliverTin`,
`T.flickTarget`, `T.showModal`/`T.dismissModal`, `T.render`.
**Benchmarks that must keep holding** (re-run after any physics/aim change):
- CPU returns ~11/12 serves (91.7%). Recent 150-sample runs: 90.7%, 89.3%, 90.7%, 88.7% (±2.5%).
Watch this — the last few sit under the bar rather than scattered around it.
**The harness for this number was never written down and I could not reconstruct it.** A rig that
counts "did the CPU get a racket on the served ball" scores 100/100, because after a flub the CPU
gets another swing at the SAME ball (`aiSwingCool = 0.5`) and connects — 91.7% ≈ 1 − aiFlub, so the
recorded number must count only the FIRST swing opportunity, or only returns that land in.
Until someone writes the rig down, prove "no tennis regression" **differentially** instead: run
your rig, `git stash` the change, reload the modules, run the identical rig, compare. Equal
numbers on the same rig is the real claim; a bare percentage against this line is not.
- 30 human-tapped points (tap when the incoming ball is 2.2–3.2 m away) = **50 hits / 0 whiffs**.
This one DOES reproduce exactly — drive it off `G.missLog` (HIT vs MISS counts).
- A 26-game state-machine soak with bombs/bursts/fire must finish with no exceptions, no stall,
`mgT` back to 0, and gore/piles arrays bounded (gore caps at 40, piles at 30).
**Two ways a soak lies to you**, both cost me a run: force-spawning zombies during `G.clearing`
makes the hand-clear un-completable and reads as a 30-minute stall; and reviving the player after
each `eaten` inflates gore past its cap, because the death push (`zombies.js`, the `eatenR` hit) is
the one uncapped `gore.push` in the file — fine at one death per run, not at 260. Respect
`clearing`, and either end the soak on death or set `TUNE.eatenR = 0` to make him untouchable.
- **Never trust a single sample.** aiFlub is 9%; a 20/24 run is normal noise. Use 150.
## Asset pipeline
1. `generate-image` skill (needs `OPENROUTER_API_KEY` in the gitignored repo `.env`; run with the
scratchpad venv python — system python lacks `requests`). Passing the existing sheet as
`--input` (edit mode) nails the style and is how every matching sheet here was made.
2. Ask for a **magenta background** and, for sheets, "at least 150px of empty magenta between
frames" plus "the prop must stay inside its own frame".
3. `python3 assets/_slice.py sheet.png out1.png ...` — chroma-key on the corner colour, magenta
de-fringe, column-gap segmentation, common-scale per sheet, `IDLE_TARGET_H=112`. `--largest`
keeps only the main blob.
4. Standalone art (logo, popup panels) uses `assets/_key.py` instead: chroma-key + crop to the
content bbox + optional downscale, no frame splitting, no forced height.
5. Register new names in `render.js`'s `SPR` preload array.
Known generator quirks, all still true:
- It sometimes ignores the magenta (harmless — the key uses the corner colour).
- It draws near-identical animation frames unless you describe strongly contrasted poses.
- **It adds props the A frames don't have** (a dragged pole on every brute B frame, a mop head). If
the prop is a separate blob, keep the largest connected component; if it touches the body, cut on
column density — a torso is dense, a dragged pole is a thin diagonal of ~7 px per column.
- **If frames merge** (a handle bridging the gap) `_slice.py` reports "found 1 frames". Re-prompt
for wider gaps first; if that fails, cut at the true column gaps plus the thinnest column inside
the merged pair, then common-scale.
- **Colour-match any regenerated frame** of an existing pair before shipping: fit a per-channel
linear map onto the original's mean and sd, then verify with a mean-difference number.
## Gotchas learned the hard way
- **Stale ES modules are the #1 time sink.** python http.server sends no cache headers, so an edited
module often loads STALE. `preview_stop`/`preview_start` only helps if the port actually changes,
and holding 8641 with a throwaway socket **did not work reliably**. What DOES work:
```js
await Promise.all(['main.js','state.js','gameplay.js','render.js','config.js','zombies.js',
'input.js','audio.js','panel.js','dom.js'].map(f => fetch(f, {cache:'reload'})));
```
then reload the page. **Always sanity-check a just-added field before trusting any result** —
and note the trap: if `state.js` is stale but `gameplay.js` is fresh, a new `G` field
materialises as **NaN** (undefined + dt), which reads exactly like a logic bug.
- `drawChar` mirrors sprites by `ch.facing` — any stance that must not mirror needs facing reset.
- Synthetic touch tests: input.js only sets `stick.moved` in touchmove, so touchstart→touchend
reads as a TAP. Always dispatch a touchmove between them.
- Contact/kill tests must step 1/60 — pellets at 26 m/s tunnel past 0.55 m radii at coarser steps
and kills silently don't happen, which looks exactly like a bug.
- **When testing fire or any serve, fire a real ARC.** A flat ball from z=16 hits the net
(netH 0.914) and bounces back at you; a "the weapon is broken" reading was my test hitting the tape.
- The scratchpad venv gets wiped between sessions — recreate it with `requests` + `pillow`.
- Commit style: imperative summary + `Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`.
Push = deploy.
---
# NEXT UP — late-game depth (agreed with Oliver 2026-07-30)
He has played 50+ games. Verdict: **nothing new happens after about game 30.** The measured cause is
in the invariants above — every difficulty dial bottomed out by game 12. Steps 1-3 removed the flat
line; **steps 4 and 5 are the actual content and are NOT built yet.** Step 6 is parked by his
decision. Read this whole section before starting: the decisions in it were argued out and settled,
and re-litigating them wastes his time.
## Settled design decisions (do not reopen)
- **Contact still kills outright. This was explicitly decided.** Player HP is a SEPARATE channel that
only ranged attacks touch. A shambler reaching you is still `G.mode = 'eaten'`, still permadeath,
still the whole run over. Oliver: *"keep the instant death separate"*. The reason: two threats that
demand two different responses is the depth; making contact chip instead of kill would make the game
easier and lose the dread that carries it.
- **Player health is his SHIRT, not a bar.** Tennis whites going progressively bloodier, plus
`bleed()` on the player once wounded. No HUD box, no meter, no number — same reasoning that removed
the boss's HP bar (see invariants). This is the aesthetic rule for this game: state is expressed in
the art or in tennis furniture, never in floating UI.
- **Healing happens by winning a sudden-death duel.** That finally gives the duel a reason to be
WANTED rather than just being a weapon vending machine.
- **Step 6 is parked.** The crowd turning into zombies and the ball-boy-zombie-who-catches-your-balls
were both discussed; Oliver: *"for 6 that's my least favourite one and we should park it"*. Do not
build them. They are recorded at the bottom only so the idea isn't lost.
## Step 4 — ranged attacks and player damage (NOT BUILT)
The point of this step: it is the only idea on the list that adds a **new verb**. Every other option
adds more of what the player already does. The player's whole vocabulary today is pull-aim-fire,
panic-swish, pick a tin, walk during a rally.
**The cheap insight to build on: a thrown projectile IS just a ball hit by the other side.**
`trySwing` already implements "an incoming ball, a generous volley window, time it or eat it", and
`VOLLEY` already widens reach ×1.4 / window ×1.8 for a pre-bounce contact. A thrower is therefore
much closer to "a racket zombie that serves at you" than to a new system. Reuse that path rather than
writing a projectile-dodging mechanic from scratch.
Deliver it as the **rogue ball machine** — Oliver's own idea and the most on-brand thing in the list,
because a ball machine is *real tennis furniture*. Suggested shape:
- A new variant (`'m'`?) that does NOT shamble: it parks on the far court and fires at you on a cycle.
- It must **telegraph**: a wind-up pose plus a sound, far enough ahead that a phone player with one
thumb can react. Reuse the `shriek()` fairness-contract pattern the runner already uses.
- Returning the shot is a normal swing/volley. Missing it costs one point of player HP and starts the
bleed. It must never be an instant kill — that is what contact is for.
- Because it doesn't walk, it never reaches your baseline, so it can't score for THEM by crossing —
it has to be a standing threat you choose when to deal with. That makes it a target-priority
decision, which is new.
Player-state plumbing this needs (none of it exists yet):
- `G.hp` / `G.hpMax` on the `G` object in state.js, reset per run.
- The bloodied-shirt skins: new `p_*` sprites per wound stage, resolved in `drawChar` exactly the way
`drawZombie` resolves boss skins — **as a fraction of hpMax, not against literal hp numbers**
(see invariants; this trap has already been hit once).
- `bleed()` currently takes a zombie; it will need to work for the player, or get a sibling.
- Healing in `resolveInterlude('P')`, alongside `deliverTin()`.
- The eaten/run-summary path must still work when death comes from HP reaching zero rather than from
contact — check `G.eatenAt`, `playerGore()`, and the `localStorage` best-run write.
## Step 5 — chapter bosses every 10 games (NOT BUILT — Oliver's favourite)
*"my favourite - we need spectacular new sprites for the bosses and his wounds"*
Why this is worth the most: it gives the run **chapters**, and it fixes a real economy problem —
tins arrive every 3 games forever (`duelFirst` 2, `duelEvery` 3), so there is currently no reason to
bank anything. A rare wall you must save weapons for is what turns the rack into a decision.
- Every 10 games. Escalating each time: more hp, bigger, more wound stages, and — once step 4 exists
— it uses ranged attacks too, so escalation becomes numbers rather than new mechanics.
- **The damage model is already ready for this.** `wound()` + `hitR()` + the `TUNE.dmg*` table mean a
boss is just a bigger `hp` number and every weapon stays proportional. Verified: a 9 hp body takes
3 off per bomb, ~3 pellets per point, 1 per side ball. Do not add per-boss damage special cases.
- **Bigger is free**: the brute already renders at 2.7 m against a shambler's 1.8 m, so sprite scale
is an existing, proven lever.
- **The always-present racket zombie must stop being "the boss" first**, or a real boss will not read
as special. Today `splatPoint` respawns one every single game you win. Decide: does the mini-boss
become rarer, or get renamed/re-skinned as an ordinary "opponent" while the chapter boss is a
distinct thing?
- Sprite work is the bulk of this step and it is Oliver's stated priority. Follow the asset pipeline
section above exactly: `generate-image` in EDIT mode with the existing sheet as `--input` is what
makes a new sheet match, magenta background, then `_slice.py` (frames) or `_key.py` (standalone).
Wound stages must be generated as a SET so they colour-match — and remember a wound stage's height
must always come from its A frame or it pops.
## Parked (step 6) — do not build without asking
- **The crowd turning.** Mechanically it is "spawn from the stands instead of the far baseline", i.e.
attacks arriving from behind and beside you, which breaks the one assumption the whole game rests
on. Highest spectacle-per-line available, and `x_crowd`/`x_crowd2` already exist. Oliver's least
favourite; parked.
- **The ball-boy zombie that catches your balls.** Denies your best lane, forcing target priority.
Parked with the above.
---
# CLOSED — the 50-game plateau, steps 1-3 (2026-07-30)
### Racket zombie's HP bar removed
See invariants. Replaced by a one-per-run "IT HAS A RACKET / IT RETURNS YOUR WEAK SHOTS / ONLY A FULL
PULL WOUNDS IT" modal from `spawnRacketZombie`, gated on `G.seen.racket`.
### "Sometimes the racket zombie doesn't seem harmable" — it was real, not a feeling
Three of the five rack slots did **nothing at all** to him, silently:
`TRIPLE` side balls skipped him (`variant === 'r'` → continue), `GUN`/horde pellets skipped him, and
fire spread could not ignite him. Meanwhile `BOMB` deleted him outright ignoring hp. All fixed via the
new `TUNE.dmg*` table and `wound()`. Verified numerically: 9 pellets kill a 3 hp boss, one side ball
takes 1 hp and is consumed, a weak side ball does nothing (correct — the `zSplatQ` gate), a bomb takes
3 off, and he now catches fire from a burning neighbour and cooks to death in 3.6 s at
`bossBurnMul` 0.4.
- **`hitR(zb)` gives him a 0.7 m contact radius** (shambler 0.55, brute 0.75) — part of "unharmable"
was simply missing him.
- **Melee is still skipped, and that is correct, not an oversight.** The player is bounded at
`P_BOUNDS.zMin` = 13.1 and the boss holds `z <= COURT.svcFar` = 5.5, so they are always on opposite
sides of the net and `meleeRange` 1.7 can never span it. Don't "fix" an unreachable path.
- **A sub-threshold ball still passes through him on purpose** so `racketAI` can rally it back. That
rally IS the feedback that the pull wasn't hard enough, so there is deliberately no clank there.
### The director un-capped
Old vs new, verified in-browser (gap s / body cap / speed m/s / advanced share / boss hp):
```
gap cap speed nasty bossHp
g 1 2.59/2.78 3/4 0.79/1.75 0.03/— 3/3
g10 1.15/1.00 12/13 1.61/1.75 0.30/0.27 4/3
g12 1.03/1.00 13/14 1.79/1.75 0.36/0.42 4/3
g20 0.71/1.00 21/14 2.52/1.75 0.60/0.42 5/3
g30 0.52/1.00 24/14 2.73/1.75 0.75/0.42 6/3
g50 0.35/1.00 24/14 2.73/1.75 0.75/0.42 9/3
```
- The spawn gap is **hyperbolic** now (`zSpawnEvery / (1 + games * zSpawnAccel)`) so it keeps
tightening instead of hitting a floor; `zSpawnMin` is only a safety stop.
- **Known, deliberate early-game change:** shamble speed used to be TIME-based (`splatT / 70`) and
maxed out after 105 seconds of splatter — i.e. during game 2 — so it was never a progression lever
and every early shambler already walked at top speed. It is games-based now, which makes games 1-10
genuinely gentler than before. Oliver has not playtested that yet; if the on-ramp feels flabby the
knob is `zSpeedPerGame`.
- Hordes scale with sets banked (`hordeGrow`) — length AND spawn rate. **`TUNE.zHordeCap` = 34 is
required**: a horde deliberately ignores the director cap, and with growth a late wave asks for
~120 spawns where the old fixed 12 s x 2.5/s could only ask for 30. A 41-body court showed up in the
soak before the cap went in.
### Regression net after all of it
30 tapped tennis points = 50 hits / 0 whiffs (unchanged). 40-game splatter soak: no exceptions,
`mgT` drains to 0, gore 40 / piles 30 / bodies capped at exactly 34.
---
# CLOSED — Oliver's 35-game retest (2026-07-27), all fixed 2026-07-30
Kept because each one is a trap worth not re-digging. Every fix was verified in the browser via
`window.T`; the numbers quoted are from those runs.
### 1 + 6. Player frozen (and stuck mid-animation) during the between-game pauses — was HIGH
**Two fixes: the first was wrong.** The movement block — pose update included — lived inside
`if (G.phase === 'rally')`, so every 'over' beat and every `pretoss` left the player standing while
zombies walked at him. The `pretoss` case is issue 6: `endHorde` calls `setupPoint()`.
The first attempt widened the block to all of splatter except `'toss'`. Oliver vetoed it on sight:
*"the mechanic where I suddenly can move freely around feels broken.. I basically can never walk
around freely - also I could walk off the screen"*. He is right — free movement for five seconds
between points, in a game where you otherwise only move while a ball is live, reads as a bug. It is
reverted, and the rule is now in Settled feel decisions above.
What actually ships, three separate pieces:
- **The dead wait too.** `held = !!G.changeover` in `updateZombies` stops every zombie's `z` advance,
its x steering, and the eat check for the breather's duration. They still bleed, still burn, still
animate — they just stand there, because the umpire called time and it is still a tennis match.
This is Oliver's own second option and the characterful one (the boys mop, everyone waits).
- **The stuck pose.** `expirePose` only ever expired `swing`/`serve`; `'run'` was the pose nothing
reset, and `P.runT` stops advancing outside the movement block, so a beat that began mid-stride
welded him to one run frame. An `else if (P.pose === 'run')` on the movement block fixes it for
every phase at once.
- **The on-screen clamp** (see Settled feel decisions) — `xVis` already existed but was applied only
in auto-fire modes, so ordinary play could walk clean off the edge. Now it always applies.
**The path where this actually bit** — worth knowing, because a clear court makes it look impossible:
a changeover normally starts only after `G.clearing` has emptied the court, so nothing is walking.
But `resolveInterlude('O')` spawns **five fresh zombies** ("the dead lose their patience") and *then*
hands back to `startChangeover` via `G.afterDuel`. Lose a duel scheduled on a changeover beat and you
get five shamblers plus five seconds with no agency. Verified fixed: they advance 0.000 m across the
breather and resume 0.75 m/s the instant "Time." is called.
If the 5 s still drags on a phone, `TUNE.changeoverT` is the knob — it is now a safe, watchable beat
rather than a dangerous one, so shortening it is purely a pacing call.
### 2. The mop boy walks on during the 1:1 duel — was LOW
`sendBoy` now returns early on `G.interlude`, and `startDuel` sets `G.boy = null`. Note the ordering
that makes this safe: `resolveInterlude('P')` clears `G.interlude` **before** calling `deliverTin()`,
so the boy still comes out with the tin.
### 3. Triple serve: the two wing balls lob off at the wrong height — was MEDIUM
The diagnosis in the old text was right. `g: ball.g` is now copied onto each clone (both push sites:
`hitServe`'s triple fan and `dragServe`'s re-fire clone) and `zombies.js` integrates
`e.vy -= (e.g || TUNE.gravity) * dt`. Verified at full pull: clones carry g = 21.16 (2.3 × the 9.2
base) instead of 9.2, and centre and wing apex heights now match exactly (2.90 m both).
### 4. The umpire calls "Time" on pauses that aren't changeovers — was LOW
`G.changeover` is now the **seconds left**, not a flag (still truthy, so every `!G.changeover` guard
reads the same), and it counts itself down near the top of `update()`, calling `voice('time')` when it
hits zero. It no longer rides the generic `G.overT` expiry — anything that overwrote `overT` mid-
breather (a melee kill taking a game, say) used to make the umpire call time on a pause that was
never a changeover. Verified with a `fetch` spy on `assets/audio/time.m4a`: an unrelated `overT`
expiry at t=0.4 s says nothing and leaves 4.6 s on the breather; the line fires exactly once, when
the breather's own clock runs out.
### 5. The transition tutorial illustration confuses people — was MEDIUM
`assets/x_howto.png` regenerated (Gemini 3 Pro, edit mode with the old sheet as `--input`, then
`assets/_key.py … 240`). The curved arrow and thumbprint are gone; it is now a press ring at the top,
a straight vertical dashed track, two chevrons pointing DOWN, and a fingertip at the bottom — with the
yellow shot arrow flying the opposite way to the zombie, which is the inversion lesson. Aspect is
unchanged (240×321 vs 240×322), so the modal still draws it at 90×120 in portrait. Checked at that
drawn size, not at full resolution.
### 7. Aiming plays the toss animation; it should stay in the pretoss stance — was MEDIUM
`update()`'s forced `sv.pose = 'serve'` for `phase === 'toss'` is now gated on `!G.aim`: while the
slingshot is drawn the server holds the pretoss stance, and only `hitServe` puts him into the
toss/smash. Verified: plain tap → pose `serve`; drawn bow → pose `idle`.
### 8. Show the current game number persistently — was LOW
`GAME N` now sits permanently under the scoreboard, right-aligned to the board's width, on the same
row as the NEXT pips and in the same dim tone (`render.js`, just before the pips block). The 1.1 s
flash when a game arms is still there.