Files
splatter-tennis/HANDOFF.md
T
wuwiandClaude Opus 5 90aefdce27 Put the whole game inside the safe area
Insetting only the HUD while the canvas stayed full-bleed wasn't what was
asked for. The canvas itself is position:fixed and inset by
env(safe-area-inset-*) now, so nothing renders under the notch, the status bar
or the home indicator, and render.js no longer offsets the HUD as well — doing
both would subtract the notch twice.

The bottom strip was a second bug: the canvas was sized by computing a scale
from innerWidth/innerHeight and setting explicit pixels, so it was laid out
against whatever innerHeight reported at load, before Safari's toolbars had
settled, and stayed short of the bottom forever after. CSS stretches it now
(100vw x 100dvh minus the insets) and fitViewport derives the logical grid from
the box it was actually given, so the aspect always matches exactly — no
letterbox, no distortion.

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

723 lines
53 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, after Oliver's FOURTH playtest round (viewport, horde survivability). All playtest
rounds are closed at the bottom. Step 6 (crowd turning, ball-boy catcher) stays PARKED by his
decision. **Awaiting his next phone playtest.**
## 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`).
Every **10 games a CHAPTER BOSS** walks on (`G.bossFight`) — it clears the stage, blocks all spawns,
and volleys at you. Every 4 games a **rogue ball machine** is wheeled out. Both attack the second
damage channel: `shots[]` chip `G.hp`, which only a won duel heals. Contact is still instant death.
Weapons are **only ever won**, in the **sudden-death duel** (`G.interlude`) against the racket
zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixture he used to be). 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.
- **There are TWO damage channels and they must never be merged.** Contact with a body is still
instant permadeath (`killPlayer`, `G.mode='eaten'`); ranged attacks chip `G.hp` (`hurtPlayer`).
Oliver settled this explicitly — two threats that demand two different responses is the depth, and
making contact chip instead of kill would lose the dread the whole game runs on. `killPlayer()` is
the single death path for BOTH causes (extracted for exactly that reason: the run summary, the
best-run localStorage write and the death animation exist once). `TUNE.hurtGrace` is what stops a
three-shot volley deleting you in one frame.
- **The player's health is drawn ON HIM and nowhere else.** No bar, no meter, no HUD number — same
rule that removed the boss's bar. `drawPlayerWounds` paints procedural spatter at FIXED anchors
(fixed so it cannot crawl frame to frame), `bleedPlayer()` adds the live trickle, and `G.hurtFlash`
washes the screen edges inward on impact. Blood is never painted into the player sprite: that is a
settled feel decision, which is why this is an overlay and not a set of bloodied-kit PNGs.
- **`shots[]` in zombies.js is the HOSTILE ball array** — the ball machine's shots and the chapter
boss's volleys. Deliberately separate from `ball` (there is only ever one of those and it is yours)
and from `extras` (also yours, and they kill zombies). Three outcomes, in this order: a swing inside
the volley window RETURNS it — as an `extras` clone at `TUNE.parryPow`, so "a parry is a real
weapon" costs nothing to build; `meleeSwipe` merely bats it away; reaching you unanswered costs hp.
**The swish path is not optional politeness** — in `pretoss` the player is rooted and cannot swing
at all, so without it a machine winding up while you lined up a serve was an unanswerable hit.
- **`isBoss(zb)`** (`'r'` or `'B'`) marks bodies that take `wound()` damage from everything instead of
being deleted by it. The brute `'g'` is deliberately NOT one: melee/bomb/fire one-shotting a brute
is a settled "power exception". Every weapon path checks it — miss one and that weapon silently
no-ops or one-shots a 34 hp boss. This has already been the bug twice.
- **The chapter boss scores NOTHING when it dies.** Its reward is the chapter card plus the parked
game beat resuming. Letting it bank a kill meant `splatPoint`'s game-over branch and
`endBossFight` both decided "what happens next" in the same frame and clobbered each other.
- **`blocksClear`** decides which bodies gate the hand-clear after a won game: NOT the racket zombie
(it keeps its residence) and NOT the ball machine (furniture — a standing threat you deal with when
you choose). Making the machine gate progression turned "decide what to prioritise" into a chore
every four games.
- **The parry is bound to the TAP, in every phase, and that is load-bearing.** `keyPressed` checks
`shots` for anything within `TUNE.parryArm` FIRST and turns the tap into a swing if so. Before that,
the parry sat behind the `phase === 'rally'` branch at the bottom of `keyPressed` — and splatter is
in `pretoss` nearly all the time, so a tap aimed at an incoming shot tossed the serve ball instead
and the return was unreachable in practice. Oliver could not land one in a whole session.
`TUNE.parryReach`/`parryWindow` are deliberately much more generous than a normal volley (2.4 m /
0.34 s), because this is a phone, one thumb, against something aimed at you. The fail cases still
exist and are what keep it a timing skill: tap before the shot is inside `parryArm`, tap after it
has passed, or don't tap. **`parryWindow` must stay <= `swingWindup + swingWindow * VOLLEY.window`**
(0.348 s) or update()'s swing clock expires before the window closes.
`meleeSwipe` deliberately does NOT touch `shots`: it runs first in `keyPressed`, so it used to eat
the very shot the parry was about to return.
- **`zb.hitT` (`TUNE.bossHitCool`) stops a LINGERING ball multi-wounding a boss.** A flame ball has
full pierce — it is not consumed on contact, so it can cut a lane — but a boss is not removed when
hit, so the same ball sat inside its radius and re-wounded it *every frame*. One flame ball killed a
16 hp chapter boss. Flame now also spends itself (`lastHit = 'X'`) on a boss specifically. Any new
weapon that is not consumed on contact must set `hitT` too.
- **No blood is painted onto the player.** Oliver: *"the player blood looks odd, appears as a painted
layer on top"*. His health reads as `bleedPlayer()`'s live drip plus the screen-edge wash on impact,
and nothing else. This is the same settled rule that governs the zombies — blood is never painted
into a sprite — and a `drawPlayerWounds` overlay broke it.
- **Boss designs are DATA: `BOSS_ART` in config.js, one row per chapter, cycling.** Each row is
`{ name, walk: [2], wounds: [...], throws: [...] }`; `spawnChapterBoss` stamps the row onto the body
as `zb.art` and `drawZombie` reads only that. Damage picks a wound stage as a fraction of `hpMax`
spread over however many stages the design HAS, and `throws` is index-clamped, so a design with
fewer throw frames than wound stages just reuses its last one. Adding a boss is a sheet plus a row —
never a change to drawZombie. Missing/unloaded sprites degrade to design 1 rather than vanishing,
which is convenient and also means **a wiring bug looks exactly like working code** (see pipeline
note 7).
- **Sprites are 2x (`SPRITE_H` 224, `_slice.py` `IDLE_TARGET_H` 224) and EVERY scale-critical sprite
must share that scale.** `RES` is 3, so the player at the baseline rasterises into ~170 DEVICE
pixels — a 112px source was being UPSCALED 1.5x, which is why near bodies looked soft. Sprites that
go through `drawChar`, `drawBoy`, `drawMachine` or the wound-stage `h *= img.height / SPRITE_H` rule
are all measured against `SPRITE_H`, so a sprite left at the old scale renders at HALF height.
That is why the remaster 2x-upscaled the ~29 frames it could not re-slice rather than leaving them.
Sprites drawn through plain `drawStatic(name, x, z, hMeters)` only care about ASPECT, so their
resolution is free.
**Heights above 224 are deliberate, not bugs**: a smash or toss pose SHOULD draw taller than idle
(`p_smash` is 322), and that ratio is exactly what `SPRITE_H` exists to express.
- **The umpire's asides are SITUATIONAL, never a timer pool.** `aside(name)` in gameplay.js is the only
entry point and enforces one shared cooldown (`TUNE.asideEvery`), so several triggers in the same
moment still yield one remark. The first version drew from a bag on a timer and Oliver's verdict was
"too much and not situation aware.. they seem random". Each line is now tied to the thing that
justifies it — a new variant appearing, the court filling up, one crossing your baseline, a game lost,
a tin delivered, a horde, a changeover, a machine, a boss, the trainer, taking a hit.
- **The portrait viewport ADAPTS to the device, and `TUNE.horizon` must ride with it.** `fitViewport`
picks `h = 270 * clamp(innerHeight/innerWidth, 1.6, 2.3)` — a fixed 270x480 is 9:16 while a modern
iPhone is ~0.46, so width bound the fit and ~150 CSS px of screen went unused. `proj()` places the
court in ABSOLUTE logical px off `horizon`, and the rack/meter hang off `H - ...`, so extra height
lands entirely in the apron between them: leaving `horizon` fixed just grew a dead green band.
It is computed as `45 + (h - 480) * 0.72` for that reason. `RES` is 4 (was 3) so an iPhone's ~1125
device px across is nearly 1:1 with our 1080 — **`RES` is the first knob to turn down if the phone
ever stutters**, it is the whole fill-rate budget.
Note `#hint` (the desktop key legend) is already `display:none` on `pointer: coarse`; it only
overlaps the rack in the desktop test pane, which is not a real bug.
- **The swish cooldown scales with how swarmed you are** (`meleeCooldown()` in gameplay.js):
`meleeCool / (1 + (near-1) * meleeCrowd)`, floored at `meleeCoolMin`, counting bodies within
`meleeCrowdR`. 1 body 1.00 s, 4 → 0.47, 8 → 0.27, 14+ → 0.22. Oliver died on every game-20 horde
because the wave grows with `hordeGrow` while the panic button was a flat 1 s; he chose scaling the
melee over shrinking the horde. **Measure the crowd BEFORE calling `meleeSwipe`** — it kills
everything in reach, so counting after counts the survivors and the cooldown barely moves.
- **The safe area is handled in ONE place: index.html's canvas rule.** The canvas is `position: fixed`
and inset by `env(safe-area-inset-*)`, so the whole game sits inside the safe region — nothing ever
renders under the notch, the status bar or the home indicator. `render.js` does NOT offset the HUD as
well (`SAFE` stays at zero); doing both subtracts the notch twice. Full-bleed with an inset HUD was
tried first and Oliver rejected it twice.
- **The canvas is STRETCHED by CSS (100vw x 100dvh minus insets) and `fitViewport` derives the logical
grid from the box it was actually given** — `cv.clientWidth/clientHeight`, never `innerWidth/innerHeight`.
Sizing it the other way round (compute a scale, then set explicit px) laid it out against whatever
`innerHeight` reported at load, before iOS Safari's toolbars had settled, and it stayed a strip short
of the bottom forever after. `100dvh` is what tracks those toolbars.
- **The swish cooldown scales with how swarmed you are** (`meleeCooldown()` in gameplay.js):
`meleeCool / (1 + (near-1) * meleeCrowd)`, floored at `meleeCoolMin`, counting bodies within
`meleeCrowdR`. 1 body 1.00 s, 4 → 0.47, 8 → 0.27, 14+ → 0.22. Oliver died on every game-20 horde
because the wave grows with `hordeGrow` while the panic button was a flat 1 s; he chose scaling the
melee over shrinking the horde. **Measure the crowd BEFORE calling `meleeSwipe`** — it kills
everything in reach, so counting after counts the survivors and the cooldown barely moves.
- **The canvas is deliberately FULL-BLEED under the notch and home indicator; the HUD is what respects
the safe area.** `viewport-fit=cover` is set, and the stands running to the screen edges look right —
but an iPhone's clock was landing directly on the scoreboard. A hidden `#safe` probe div carries
`env(safe-area-inset-*)` (env() is only readable from CSS), `measureSafe()` converts it to LOGICAL
canvas px using the same scale `fitViewport` just applied, and `SAFE.top`/`SAFE.bottom` shift the
scoreboard down and lift the rack + `METER_Y` up. Anything new that anchors to the very top or very
bottom of the screen must offset by these too — and the rack's hit test shares `rackGeom`, so taps
follow automatically.
- **`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.
6. **New sprite not showing up? Bump `SPRV` in render.js.** Sprite loads go through
`assets/<n>.png?v=SPRV` for the same reason index.html versions main.js: python http.server sends
no cache headers, so the browser caches sprite responses heuristically — **including 404s**. Add a
name to the `SPR` list before its PNG exists, load the page once, and that browser treats the
sprite as permanently missing while `drawZombie` silently falls back to another design. Bump SPRV
alongside `main.js?v=N`.
7. **When a scripted `str.replace` edit is meant to rewire logic, ASSERT it matched.** A silent
no-op replace left `drawZombie` on its old hardcoded boss names while everything else had moved to
the BOSS_ART table, and the built-in fallback then hid the failure perfectly — all four boss
designs rendered as design 1 and nothing errored. Cost a full debugging round. `drawImage`
instrumentation is what finally settled it:
```js
const o = CanvasRenderingContext2D.prototype.drawImage;
CanvasRenderingContext2D.prototype.drawImage = function (img, ...a) { log(img.src); return o.call(this, img, ...a); };
```
8. **NEVER let two sprite names differ only by CASE.** macOS is case-insensitive: writing
`assets/z_B1.png` for the chapter boss silently OVERWROTE the 'b' shambler's `assets/z_b1.png`
(and `z_B2` ate `z_b2`), so a 3-metre boss started showing up in the shambler crowd and `git`
reported the shambler frames as "modified" rather than reporting a new file. `ls` shows only the
ORIGINAL lowercase name, which is why it is easy to miss. Boss frames are `z_boss*` now. Use
all-lowercase, whole-word names.
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.
---
# CLOSED — Oliver's fourth playtest (2026-07-30)
### The game did not use the full height of his iPhone
Fixed by making the portrait logical height adaptive and letting `horizon` ride with it — see the
invariant above. On a 375x812 pane: logical 270x585 (was 270x480), canvas CSS 375x812, **100% of both
axes**. `RES` 3 -> 4 for the "extra pixels" he asked about.
### Dying on every game-20 horde
The swish cooldown now scales with crowding — see the invariant. Note the ordering trap recorded
there; the first implementation measured the crowd after the swipe had already killed it, so the
cooldown only moved once ~14 bodies were present.
### The weapon message was lost when the heal popup fired
`showModal` deliberately never clobbers an open modal, so winning a duel while wounded opened the
heal modal and the weapon modal was silently dropped. `deliverTin(healLine)` now folds the trainer
into the WEAPON popup as an extra line, so there is one popup carrying both. An unwounded duel win
still shows the plain weapon popup.
### The mop boy walked out with nothing to mop
`sendBoy` now returns early when `mop` is set and no stain passes the same test `mopRound` uses to
pick a target (`a >= 0.15 && z >= COURT.netZ - 2`) — he was walking on, finding nothing reachable,
and walking straight off. This needs `state.js` to read `gore`, which adds a **third runtime-only
import cycle** (state <-> zombies) alongside input<->gameplay and zombies<->gameplay; it is safe for
the same reason, `gore` is only touched inside `sendBoy` at runtime, never during module evaluation.
### Regression net
30 tapped tennis points = 50 hits / 0 whiffs. 45-game soak: no exceptions, all four chapters,
`mgT` drains, gore 40 / piles 30 / bodies capped at 34.
---
# CLOSED — Oliver's third playtest: audio, hit timing, readability (2026-07-30)
### The umpire talked too much and at random
Rebuilt as situational triggers behind one shared cooldown — see the invariant above. `asideEvery`
26 s -> 55 s. Removed `ump_out_of_order`, `ump_let`, and `ump_no_appeal` (he asked for
"ump_no_deal"; that line does not exist and `ump_no_appeal` is the one he meant — confirm if a
different line is still unwanted). 11 `ump_*` lines remain.
### "Visually the ball is not yet at the player but somehow the player already is hit"
Real, and measurable. The hostile-ball hit test reused the PARRY's `hypot(dx, dz * 0.8)` — weighting
depth by 0.8 SHRINKS it, so contact fired while the ball was still most of a metre up-court. Measured
at the moment of the hit: `dz = -0.68 m`, and the ball drawn at screen y 314 against a head at 305 —
up around his head, not on his body. Fixed with a separate unweighted test (`TUNE.shotHitR` 0.42,
`shotHitY` 1.75). Re-measured: contact at `dz ≈ -0.25 m`, ball at screen y 325 against head 305 /
feet 362 — squarely on the chest. **The parry keeps its 0.8 weighting on purpose**: a racket sweep
really is wider than it is deep.
### "Everything is a tad too small" (his wife, and the best note of the project)
Measured before changing anything, because "regenerate the sprites at higher resolution" is only half
right. With `RES` 3, drawn DEVICE pixels vs the 112px source were:
```
player at baseline 170 px UPSCALED 1.52x <- higher-res art helps
shambler your half 120 px upscaled 1.08x <- helps
shambler mid-court 98 px downscaled <- cannot help; needs to be BIGGER
shambler far 78 px downscaled 0.70x <- cannot help
```
So near bodies needed resolution and far bodies needed the camera. Both were done.
**Camera** (portrait only; landscape untouched): `camZ` 42->48, `camF` 580->760, `camY` 9->11,
`horizon` 90->45. Pushing the camera back while lengthening the lens FLATTENS the perspective, so
distant bodies grow relative to near ones instead of everything merely zooming; `camY` rises with it
for a steeper look-down so bodies overlap less at depth, and `horizon` re-centres the court after
both. Result: far shambler 26 -> 30 logical px, mid 33 -> 36, chapter-1 boss 39 -> 45, **player
unchanged at 57**, court band 200..390 (was 199..380). If you retune these four, check all four
outputs: far size, near size, court top, court bottom.
**Sprite remaster, at zero generation cost.** The original `assets/_sheet_*.png` files are retained at
full generated resolution (~1400px wide), so nothing needed regenerating — `IDLE_TARGET_H` went to 224
and every sheet was RE-SLICED. The sheet-to-output mapping was never written down, so it was recovered
automatically: slice each sheet to a temp dir, then greedily assign frames to existing assets 1:1 by
LANCZOS-normalised RGBA signature distance. 58 sprites came back from sheets; the 29 with no confident
sheet match were 2x-upscaled instead, which adds no detail but keeps them on the shared scale (see the
`SPRITE_H` invariant — a mismatched sprite renders at half height). `x_crowd`/`x_crowd2` were reverted
to originals: they never touch `SPRITE_H` and doubling them is pure memory.
**Known gap:** `p_idle1`/`p_idle2` and most `o_*` frames were among the 2x-upscaled set, so the two
most-seen poses gained scale but not detail. Re-slicing `_sheet_player2/3` and `_sheet_opp2/3` by hand
(rather than via the matcher) would fix that.
### Regression net
30 tapped tennis points = 50 hits / 0 whiffs. 45-game soak: no exceptions, all four chapters fired,
`mgT` drains, gore 40 / piles 30 / bodies capped at 34.
---
# CLOSED — Oliver's second boss/ranged playtest (2026-07-30)
### The parry was too generous
*"I can tap pretty early and it returns the ball."* At `parryArm` 6.0 / window 0.34 / reach 2.4 the
shot flew INTO reach during the window, so any tap once it was vaguely close worked. Now 3.4 / 0.2 /
1.7 — verified: a tap at 6.0 m or 4.5 m is rejected and costs blood, 3.2 m and closer returns.
### The boss's three balls were decoration
*"he throws 3 of them from which only ever one can reach me. that does not make sense."* Correct — they
were fanned +-1.9 m across space, so only the middle one was ever aimed at the player. They are a
**timed salvo** now: three shots `TUNE.bossSalvoGap` apart, each aimed at where you are AT THAT MOMENT
with only a hand's width of scatter. Verified firing at 0.02 / 0.45 / 0.88 s, all within 0.3 m of the
player. Note `hurtGrace` (1.1 s) still caps a whole volley at one point of damage by design.
Reaction time also tightened: bosses get `bossWind` 0.5 s of telegraph against the machine's 0.9, and
throw at `bossShotSpeed` 15.5 m/s against its 13.
### Every chapter was the same boss
Right — chapters only differed in hp and scale. There are three designs now, cycling, each with its
own sheet, a 2-frame stride, a throw pose, a wrecked skin and a wrecked-throw skin:
**THE GROUNDSKEEPER** (ch 1/4/7…, blazer + ball hopper, and the only one with three wound stages),
**THE LINE JUDGE** (ch 2/5/…, fat, striped officials' polo, cap, ball apron),
**THE CHAIR UMPIRE** (ch 3/6/…, tall and gaunt, panama hat, the umpire's chair strapped to his back).
The chapter card names the design. Chapter 4 repeats design 1 but at 43 hp and 3.67 m.
**The chair-umpire sheet came back on a WHITE background**, and it wears a cream blazer and a white
hat — a colour key would have eaten both. Fixed by flood-filling the background inward from the image
BORDER instead of keying by colour; that script is in this session's git history and is the right tool
whenever a generated sheet's background colour also appears on the character.
### Healing needed to announce itself
Winning a duel while wounded now opens a real modal — `x_cross` first-aid tin, "WOUNDS TREATED / THE
TRAINER PATCHED YOU UP / N OF M BACK" — plus an umpire line. It was a 1.4 s flash before.
### The umpire needed to comment on the new reality
14 new recorded lines (`say -v Daniel -r 168` -> `afconvert`), all `ump_*`. Ten are a rotating pool of
sarcastic asides fired on a jittered `TUNE.asideEvery` timer whenever nothing else owns the audio
(`umpireAside` in gameplay.js); four are hooked to specific beats — a machine arriving, a boss
arriving, the trainer, and taking a hit. The joke is that he has still never acknowledged that the
match became a massacre, so he keeps officiating it: *"Code violation. Unsportsmanlike conduct.
Eating."*
### Regression net
30 tapped tennis points = 50 hits / 0 whiffs. 45-game soak: no exceptions, `mgT` drains, gore 40 /
piles 30 / bodies capped at 34.
---
# CLOSED — Oliver's first boss/ranged playtest (2026-07-30)
Six reports, all fixed. Two were serious.
### A boss sprite appeared in the shambler crowd — CASE COLLISION
`z_B1.png` / `z_B2.png` overwrote the 'b' shambler's `z_b1.png` / `z_b2.png` on a case-insensitive
filesystem. Restored from `HEAD~1`; boss frames renamed `z_boss1`, `z_boss2`, `z_bossw1..3`. The rule
is now in the asset pipeline section — this is a trap that leaves no obvious trace.
### The parry was unreachable — the whole mechanic was dead
*"I could not return a single ball of the ballmachine.. I tried it with taps near the hit - it never
worked"*, and the same for the boss's volleys. Cause: `P.swingT` is only set in `keyPressed`'s
`phase === 'rally'` branch, and splatter is in `pretoss` almost all of the time, so the tap tossed
the serve ball instead of swinging. An incoming shot now owns the tap in every phase — see the
invariant above. Verified returning from `pretoss`, `rally` and `over`, at 0.9 m through 5.4 m, with
the three fail cases (too early / too late / no tap) intact.
### One flame ball killed a 16 hp boss
Not tuning — a real bug. The flame ball is not consumed on contact (full pierce) and a boss is not
removed when hit, so it re-wounded every frame. `zb.hitT` + spending the ball on a boss. Chapter 1
now costs 4 flame balls or 16 clean smashes, and `bossHp` went 10 -> 16 (`bossHpPerChapter` 8 -> 9),
so chapter 4 is 43 hp.
### The boss had no wounded state while throwing
Correct — the wound skins were standing poses only. New sheet `_sheet_bossthrow.png` gives all four
damage states their own overarm pitch (`z_bosst0..3`), used whenever `windT > 0`.
### The ball machine was "naively painted"
It was — four rects. Replaced with a generated sheet (`z_mach1` idle, `z_mach2` firing with a muzzle
flash): a dented hopper cart on wheels with a barrel aimed down-court. The muzzle GLOW stays
procedural on top, because the telegraph has to read at 20 px tall.
### The player's blood read as a painted layer
Removed entirely. See the invariant above.
# CLOSED — chapter bosses + ranged attacks, steps 4-5 (2026-07-30)
## Step 4 — the ranged channel
**The ball machine (`'m'`)**, Oliver's own idea and the most on-brand thing on the list, because a ball
machine is real tennis furniture. It never walks, so it never crosses your baseline and never scores:
it is a standing threat you choose when to deal with, which is the target-priority decision the game
was missing. Measured: 3.4 s cycle, **0.9 s telegraph** (`TUNE.machWind`, muzzle glow + a mechanical
click — the same fairness contract the runner's `shriek()` is), 3 hp, exempt from the clear gate.
**The projectile is a real ARC**, solved like a serve rather than fired flat, so it reads coming in and
so the swing window has something to meet. `fireAtPlayer` aims where you *stand*, which means moving
is a partial answer during a rally — and is exactly why the swish path exists for `pretoss`, where you
cannot move or swing.
**Player damage.** 4 hp, `hurtGrace` 1.1 s, +2 on a won duel (the only healing in the game, and now
THE reason to want a duel rather than tolerate it). Verified: 4 hits kill you and land in the same
`eaten` path as being caught, with `eatenAt`, the death animation and the best-run write all intact;
three hits in one frame cost only one point.
## Step 5 — chapter bosses, and the racket zombie demoted
**Demoted, as Oliver asked.** The racket zombie used to respawn on *every* game you won, which is
precisely why he read as "the boss" and why a real boss could not have stood out next to him. He is an
occasional opponent now (`TUNE.racketEvery` = 3 games).
**The chapter boss (`'B'`)** arrives every `TUNE.bossEvery` = 10 games, fired from `proceedAfterClear`
— the one beat where the court is guaranteed empty — and it OUTRANKS the set/horde and duel beats
there, because a boss walking on into a standing wave would be unreadable. It clears the stage,
blocks all other spawns while it lives, and threatens both channels: volleys of `bossVolley` = 3
chip your hp, and letting it reach you is the ordinary instant death. Chapter 1 is 10 hp and 2.7 m;
chapter 4 is 34 hp and 3.67 m.
**The economy fix this was really for:** tins arrive every 3 games forever, so nothing was ever worth
banking. Measured cost of a chapter-4 boss — 12 bombs, or 100 machine-gun rounds, or 34 side balls.
`TUNE.wCap` is 9 per type, so **no single weapon can kill a late boss** and a mixed, banked arsenal is
mandatory. That is the strategic pressure Oliver was asking for.
`G.bossDue` never moves backwards (`Math.max`): going a long stretch without ever clearing the court
skips a chapter rather than queueing a backlog. Deliberate — you face the boss the run has earned.
## Bugs found and fixed while building this (all were silent)
- A blast **deleted** a 34 hp chapter boss outright: `explode` only wounded `'r'`. This is what
`isBoss()` now exists to prevent, and it is the second time the same class of bug has shipped.
- The machine's `machHp` did nothing — `'m'` fell through every variant branch to the generic
one-shot fallback. It soaks like the brute now (3 balls, verified).
- The boss both scored a kill and fired its chapter card, so two code paths set "what happens next"
in the same frame.
- The machine gated the hand-clear, turning a priority decision into a chore.
- A boss death produced a shambler-sized gore burst (`GORE_MUL` had no `'B'`; now 6 → ~305 particles).
## Regression net after steps 4-5
30 tapped tennis points = **50 hits / 0 whiffs** (unchanged — tennis is untouched by any of this).
45-game splatter soak: no exceptions, **all four chapters fired [1,2,3,4]**, machines appeared, ranged
hits landed, `mgT` drains, gore 40 / piles 30 / bodies capped at 34.
---
# 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.