Add POLISH-PLAN.md — implementation plan for the polish + bugfix round

Fourteen items from Oliver's phone-test list, written so a fresh session can
execute them. Every root cause was verified against the code rather than
guessed; the two "bugs" both turned out to be real and both have a named line:
wCap 3 caps the stock, and weapons leak in through a SECOND delivery path
(splatPoint's tinDue -> startChangeover) that has nothing to do with the duel.

The serif complaint is also a bug, not taste: only 4 of 15 canvas font strings
ask for Silkscreen, the rest say bare "monospace" and land on Courier.

Ordered so the three foundations come first — the modal system (five separate
popup requirements need the same input-gated pause, which does not exist yet),
the UI colour constants, and the font fix — since the later items draw through
all three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-07-27 04:48:43 +03:00
1 parent c8990fe900
commit 64b297ce54
1 file changed
+501
+501
View File
@@ -0,0 +1,501 @@
# Splatter Tennis — Polish & Bugfix Plan
Written 2026-07-27 for a fresh Claude session. Everything below was verified against the
code at commit `c8990fe`; every "cause" line is something I actually read, not a guess.
**Read first:** `HANDOFF.md` (architecture, benchmarks, hard-won gotchas). This plan assumes it.
## How to work in this repo
- Zero build. Plain ES modules served by `python3 -m http.server` via `.claude/launch.json`
(`preview_start` with `{name: "splatter-tennis"}`).
- `config.js` = all tuning (`TUNE` is live-editable in-game via the `T` panel, which
auto-lists every key — new keys appear for free). `state.js` = all mutable state on `G`.
`gameplay.js` = rules/physics/update. `render.js` = all drawing. `zombies.js` = the splatter
sim. `input.js` = keys + touch. `main.js` = boot + the `window.T` test surface.
- **Stale modules are the #1 time sink here.** After editing, bump `main.js?v=N` in
`index.html` AND navigate to a fresh page URL (`http://localhost:8641/?cb=<new>`). Then
sanity-check freshness before trusting any result: `T.TUNE.someNewKey !== undefined`.
Bumping `?v=` also guarantees Oliver's phone picks up the build.
- RAF pause state varies per session. Probe it; halt with
`window.requestAnimationFrame = () => 0` before deterministic `T.step` tests.
- Synthetic touch needs a `touchmove` between start and end or it reads as a tap. Contact
tests must step `1/60` (coarser steps tunnel).
- Verify each item with a runnable check before moving on. Screenshot the visual ones at
`resize_window` mobile (375×812) — this is a phone game.
## Order of work
Items 1–3 are **foundations** that later items depend on. Do them first, in order.
Items 4–13 are independent after that and can be done in any order.
---
# 1. FOUNDATION: the modal system
Five separate requirements ("popup, game only continues after tapping it away") need the
same thing, which does not exist yet. Build it once.
**What exists today:** `G.card` (`state.js:60`) + `drawCard()` (`render.js:326`) draw a
centred banner, but they are purely cosmetic — the game keeps running underneath on an
`overT` timer. There is no input-gated pause anywhere.
**Build:**
`state.js` — add to `G`:
```js
modal: null, // {title, body: [lines], icon, color, onDismiss} — blocks play until tapped
seen: {}, // one-shot markers so a modal only fires the first time this run
```
`gameplay.js` — add near the card helpers:
```js
export function showModal(m) { if (!G.modal) G.modal = m; }
export function dismissModal() {
const m = G.modal; if (!m) return false;
G.modal = null; if (m.onDismiss) m.onDismiss();
return true; // tells the caller the gesture was consumed
}
```
`gameplay.js update()` — freeze at the very top, after the flash/card decay so nothing
expires while the player reads:
```js
if (G.modal) { G.idleT += dt; return; } // everything else waits
```
Put this AFTER the `hitstop` and `eaten` early-returns and AFTER `G.flash`/`G.card` decay,
but BEFORE the movement block. Rationale: the world must be completely frozen (no zombie
steps, no ball flight, no spawn timers) or a player reading a popup gets eaten.
`input.js` — dismissal must take priority over everything and swallow the gesture:
- In `touchstart`, first line inside the `for` loop: `if (dismissModal()) return;`
- In the `keydown` handler: any key dismisses.
- Guard `canAimServe()` with `!G.modal`.
`main.js` — `maybeRestart` must not fire through a modal.
`render.js` — a `drawModal()` drawn LAST (after the cards, before nothing). Spec:
- Full-screen scrim `rgba(8,6,10,0.82)`.
- Optional icon sprite centred above the title (used by the weapon popup).
- Title in the UI accent colour, body lines below, `TAP TO CONTINUE` blinking at the bottom.
- Use the pixel font (see item 3) — this is new text, so do not introduce more `monospace`.
**Acceptance:** with a modal open, `T.step(1)` × 60 must leave `zombies[0].z`, `ball.z` and
`G.splatT` unchanged; a synthetic `touchstart` clears `G.modal` and does NOT serve.
---
# 2. FOUNDATION: UI colour palette
**Requirement:** "Farbpalette etwas absoften (sehr starke rot und gelb töne in schrift und
interaktiven elementen)".
**Cause:** the hot colours are hard-coded as hex literals scattered through `render.js`.
`grep -o "#[0-9a-fA-F]\{6\}" render.js | sort | uniq -c` gives the inventory. The offenders:
| current | where | proposed softer |
|---|---|---|
| `#f8e048` | aim reticle, hints, NEW BEST, TAP TO PLAY AGAIN (5×) | `#e0cc72` |
| `#ff5050` | locked-target reticle, out-marker, SUDDEN DEATH card (3×) | `#d9705f` |
| `#e6ee3a` | selected rack slot, SET card (4×) | `#cbd06a` |
| `#e8f03a` | the ball itself (2×) | `#dbe063` |
| `#c02020` | GAME SET MASSACRE | `#a34a42` |
| `#e0231a` / `#eef23a` | intro logo CSS in `index.html` | see item 10 (logo art replaces it) |
**Do NOT** soften the gore reds (`PAL`/blood particles) — the splatter should stay loud;
only text and interactive UI were named.
**Build:** add a `UI` export to `config.js` next to `TUNE` (a plain object, not inside
`TUNE` — the T panel only renders numbers):
```js
// UI accent colours, one place so the whole HUD can be re-tuned at once. Deliberately
// desaturated from the original arcade primaries: they were shouting over the artwork.
export const UI = { hot: '#d9705f', warm: '#e0cc72', pick: '#cbd06a', cream: '#e8e8dc',
dim: '#8fa0b8', bad: '#a34a42', ball: '#dbe063' };
```
Then replace the literals in `render.js`. Keep one commit for the mechanical swap so a
revert is trivial.
**Acceptance:** screenshot before/after at mobile size; no remaining `#f8e048` / `#ff5050`
/ `#e6ee3a` / `#c02020` in `render.js`.
---
# 3. FOUNDATION: the serif font bug
**Requirement:** "Die Serifen-Schrift ist nicht schön. Die sieht so generisch aus."
**Cause — this is a real bug, not taste.** `index.html` bundles Silkscreen and `main.js`
warms it, but only 4 of 15 canvas font strings actually ask for it. Everything else says
`bold Npx monospace`, which resolves to Courier — a slab **serif**. Confirmed:
`grep -n "ctx.font = " render.js` → lines 329, 330, 560, 562, 566, 569, 572, 575, 588, 597,
603 all use bare `monospace`. Lines 314, 316, 534 correctly use `Silkscreen`.
**Build:** `render.js:534` already has the helper —
`const F = (sz, b = 1) => \`${b ? 700 : 400} ${sz}px Silkscreen, monospace\`;`
Hoist it to module scope (it currently lives inside the scoreboard block) and route **every**
`ctx.font =` through it. Silkscreen has no lowercase-friendly metrics at odd sizes; keep the
existing px sizes but expect slightly different widths — re-check the centred strings
(`GAME, SET, MASSACRE.` at 16px is the widest) still fit at 270 logical px wide (portrait).
Also fix the `#hint` line in `index.html` (`font: 12px monospace`) — same serif, and it is
visible under the canvas on desktop.
**Acceptance:** `grep -c "px monospace" render.js` returns 0 except inside `F`'s fallback
chain. Screenshot the eaten screen and a card in portrait; no serifs, nothing clipped.
---
# 4. BUG: weapon stock caps at 3
**Requirement:** "es können pro waffe immer nur drei gehalten werden. es sollten mehr waffen
angespart werden können."
**Cause:** `config.js:68` `wCap: 3` and `gameplay.js:336`
`const add = k => Math.min(TUNE.wCap, k);` inside `deliverTin()`.
**Build:** raise `wCap` to `9`. Check the rack renderer handles two-digit counts:
`render.js` draws `'×' + n` in the slot at 6px — at 270px wide with 5 slots each slot is
~50px, so `×9` is fine but verify `×12` if you go higher. Recommend 9 and stop.
While here: `deliverTin()` gives `TUNE.bombMax` (3) bombs but hard-codes `3` triples and
`3` flames and `1` gun. Pull those into `TUNE` (`tinTriples`, `tinFlames`, `tinGuns`) so the
economy is tunable from the panel like everything else.
**Acceptance:** `T.deliverTin()` four times in a row, then check
`T.G.bombs > 3`. Rack renders the count without overflowing its slot.
---
# 5. BUG: weapons arrive whether or not you win the duel
**Requirement:** "die waffen bekommt man unabhängig ob man das zombie 1:1 match gewonnen hat
oder nicht."
**Cause — there are two independent delivery paths and only one is gated on the duel:**
1. `resolveInterlude('P')` → `deliverTin()` (`gameplay.js:355`) — the reward for winning.
2. `splatPoint()` sets `G.tinDue` on a game schedule (`ballChangeFirst`/`ballChangeEvery`),
and `startChangeover()` (`gameplay.js:310`) calls `deliverTin()` when `tinDue` — with no
reference to any duel. This is the leak.
Note path 2 has a partial gate already: `G.shameGame` withholds the tin if *they* took a
game since the last delivery. That is not the same as winning the duel.
**Build:** make winning the duel the ONLY source of weapons.
- Delete the `tinDue` scheduling in `splatPoint()` and the `deliverTin()` call in
`startChangeover()`. Keep the "CHANGEOVER" flash. `G.tinDue` / `TUNE.ballChangeFirst` /
`ballChangeEvery` / `G.shameGame` all become dead — remove them rather than leaving
vestigial state.
- Keep `resolveInterlude('P') → deliverTin()`.
**Consequence to check:** duels only trigger when the court is clear and a racket zombie is
the last one standing (`gameplay.js:~760`). If that turns out to be rare, weapons become
near-unobtainable. **Measure before shipping:** run a 5-minute simulated session and count
duels. If it's under ~1 per two games, tell Oliver rather than silently re-adding a second
source — the design question (how often should duels happen?) is his call.
**Acceptance:** simulate a full game where the player loses the duel; `G.bombs` etc. must be
unchanged. Win one; exactly one tin arrives.
---
# 6. POPUP: entering splatter mode from tennis
**Requirement:** popup with an **illustration** explaining the new mechanic, stays until
tapped.
**Trigger:** `gameplay.js:410`, inside `startTransition()`'s timeline —
`once('go', 8.2, () => { G.mode = 'splatter'; ... })`. Fire the modal there, after
`setupPoint()`.
**Content:**
- Title: `THE MATCH HAS CHANGED`
- Body: pull back to aim · the further you pull, the harder it hits · a weak shot only
staggers them
- Icon: a generated illustration (see Appendix A). Ask for a pixel-art panel showing the
player pulling the slingshot aim back with a chevron trail toward a shambler — it should
teach the gesture, not just decorate.
Because this is the mechanic tutorial, it is worth the biggest illustration of the set.
Consider a wider modal layout for this one (`icon` sized ~120×80 logical px).
**Acceptance:** enter splatter; the world is frozen and no zombie moves until tapped.
---
# 7. POPUP: entering the 1:1 duel (first time)
**Requirement:** popup saying it is a normal tennis game again and that winning earns a
weapon. "momentan gibt es schon ein popup aber auf dem steht auch anderer unnötiger kram."
**Cause:** `render.js:593` draws
`drawCard('SUDDEN DEATH', 'WIN THE POINT · THE LINES ARE LIVE', 'THE POINT WINS YOU A NEW WEAPON', '#ff5050')`
on a 2.4s timer (`G.interlude.introT`, set at `gameplay.js:764`). It is not dismissible, it
disappears on its own, and "THE LINES ARE LIVE" is the unnecessary cruft.
**Build:** replace the timed card with a real modal, gated on `G.seen.duel`:
- Title: `SUDDEN DEATH`
- Body: `IT'S TENNIS AGAIN — ONE POINT` / `WIN IT AND THEY HAND YOU A WEAPON`
- On dismiss: start the point.
- Delete the `introT` card path in `render.js` and the `introT` countdown in `update()` if
the modal fully replaces the beat. Keep `G.interlude.introT` only if the umpire voice line
still needs the pause — check `voice('sudden_death', 2.4)` timing at `gameplay.js:~766`.
- Subsequent duels in the same run: no modal, keep a short flash.
**Acceptance:** first duel opens a modal that blocks serving; second duel does not.
---
# 8. POPUP: weapon received
**Requirement:** shows and describes the weapon; game continues only after tapping.
**Trigger:** inside `deliverTin()` (`gameplay.js:335`), replacing the
`G.flash = { text: 'NEW BALLS: ' + SLOTS[w+1] }` line.
**Content:** icon = the existing rack sprite (`w_bomb` / `w_triple` / `w_fire` / `w_gun` —
already loaded in `render.js`'s `SPR`), title = the weapon name, one line of description:
| slot | name | line |
|---|---|---|
| 1 | BOMB | detonates on contact — clears a crowd |
| 2 | TRIPLE | one serve, three balls |
| 3 | FIRE | kills on any touch and **sets the others alight** (item 12) |
| 4 | GUN | hold the line and spray — a burst, not a serve |
**Careful:** `deliverTin()` is also called from `resolveInterlude()` which is itself called
from `killZombie()` mid-iteration over `zombies[]` (`zombies.js:51`). Opening a modal there
is safe (it only sets state) but do NOT make `showModal` do anything that touches the
zombie array.
**Acceptance:** win a duel → modal with the right icon; play is frozen until tapped.
---
# 9. POPUP: the horde after a won set
**Requirement:** popup explaining you now have to shoot with the gun; blocks until tapped.
**Trigger:** `proceedAfterClear()` sets `G.pendingHorde` and shows the SET card
(`gameplay.js:290-302`); the horde starts when the `overT` beat expires (in `update()`'s
`over` branch → `startHorde()`).
**Build:** keep the SET card (it is the milestone beat and Oliver asked for it originally —
see the memory note on milestone cards), then show the modal instead of auto-starting:
- Title: `THEY KEEP COMING`
- Body: `THE RACKET FIRES ON ITS OWN` / `SLIDE TO AIM · HOLD THE BASELINE`
- On dismiss: `startHorde()`.
Note `startHorde()` already pins `P.z` to the baseline and switches input to absolute
finger-steering (`touchPX()`), which is a genuinely different control mode — that is exactly
why the popup is warranted.
**Acceptance:** win a set → SET card → modal → horde only begins after the tap.
---
# 10. Start-screen logo
**Requirement:** "ein schöneres Splatter Tennis Logo für den Startscreen generieren."
**Cause:** the current logo is CSS text (`index.html`, `#intro .l1` / `.l2`) — a hard red
`#e0231a` over hard yellow `#eef23a` with stacked text-shadows. Generic.
**Build:** generate a pixel-art logo PNG (Appendix A), drop it at `assets/logo.png`, replace
the two `<span>`s with `<img>`. Keep the `drop` animation and the blinking `TAP TO START`.
Prompt direction: bold pixel lettering, tennis-ball yellow-green and dried-blood red, a
dripping-gore underline, transparent background, roughly 640×280.
Soften the surrounding CSS colours to match item 2 while you are in there.
**Acceptance:** screenshot the intro at mobile and desktop; the logo must not overflow
`max-width: 92vw` and must stay crisp (`image-rendering: pixelated` on the `img`).
---
# 11. The brute takes 5 hits, with more wound states
**Requirement:** big zombie harder to kill (5 hits), correspondingly more wound states,
sprites to be generated.
**Cause:** `config.js:80` `bruteHp: 2`. `render.js:160` has exactly one wound skin:
`if (zb.variant === 'g' && zb.hp < TUNE.bruteHp && SPR.z_gw) name = 'z_gw';`
**Build:**
- `bruteHp: 5`.
- Generate `z_gw2`, `z_gw3`, `z_gw4` (Appendix A) as a progression: 4/5 hp = one arm torn
off (that is today's `z_gw`), 3/5 = both arms, 2/5 = jaw/half the head gone, 1/5 = barely
standing, dragging itself. Register them in `render.js`'s `SPR` preload list (line ~75).
- Select by hp:
```js
if (zb.variant === 'g' && zb.hp < TUNE.bruteHp)
name = ['z_gw4', 'z_gw3', 'z_gw2', 'z_gw'][Math.max(0, zb.hp - 1)] || 'z_gw';
```
(guard each with an `SPR[...]` existence check like the current code does, so a missing
asset degrades to the walk frame instead of drawing nothing.)
- `bleed()` (`zombies.js:158`) picks wound anchors per variant — extend the brute branch so
later stages bleed from more points. Keep it sparse; the settled feel note is explicit
that blood must stay thin (see `HANDOFF.md` and the feel memory).
**Balance check:** the brute already soaks machine-gun pellets one per hit
(`zombies.js` pellet loop). At 5 hp it costs 5 pellets — fine during a horde, but verify a
brute arriving during normal play is still killable with the ~1.4s serve cycle before it
reaches the baseline. Measure `time-to-kill vs time-to-baseline`; if it is unwinnable, say
so rather than quietly lowering the hp Oliver asked for.
---
# 12. Fire spreads
**Requirement:** the fireball kills but **ignites** surrounding zombies; a burning zombie
that walks near another sets that one alight too; needs a dynamic fire animation.
**Cause:** today `ball.flame` (`zombies.js:245`) is instant-kill-on-touch with full pierce,
and nothing else. The only fire visual is the ball's ember trail
(`gameplay.js` pushes `parts` with `fire: true`; drawn at `render.js:496`).
**Build — new state on the zombie:**
```js
zb.burnT // seconds of burning left; undefined = not on fire
```
`config.js`:
```js
burnTime: 3.0, // s a zombie burns before it collapses
burnDps: 1.0, // hp lost per second while burning (so burnTime*burnDps must exceed hp)
burnSpread: 1.3, // m — a burning body ignites anything this close
burnIgniteR: 2.6, // m — the flame ball's initial ignition radius around the kill
```
`zombies.js`:
- On a flame-ball contact: kill the struck zombie as now, then ignite every zombie within
`burnIgniteR` (`zb.burnT = TUNE.burnTime`).
- In `updateZombies`'s per-zombie loop: if `burnT > 0`, decrement it, apply `burnDps * dt`
damage (kill via the existing `killZombie` path so gore/scoring/ball-boy all still fire),
emit 1–2 `fire: true` particles per frame from the body, and ignite any un-burning zombie
within `burnSpread`.
- **Chain-reaction hazard:** `killZombie` splices the array and `splatPoint` can cascade.
The existing loops guard with `if (!zb) continue;` — the ignition pass must do the same and
must iterate backwards. `HANDOFF.md` calls this out explicitly; a fire chain is the worst
case for it, so write the loop defensively and test with 15 zombies packed together.
- Burning zombies should walk **faster** briefly (panic) — a nice touch and it makes fire a
double-edged weapon. Optional; flag it to Oliver.
`render.js` — the dynamic animation:
- Draw flames on a burning body procedurally rather than as sprites: 3–5 vertically-stretched
flame quads per zombie, offsets driven by `Math.sin(G.idleT * f + zb.walkT)` so each body
flickers on its own phase, colours `#ffc030` → `#ff8010` → dark smoke as `burnT` runs out.
This is cheaper than a sprite sheet and reads better at 4 different burn stages.
- Tint the zombie sprite toward orange while burning (`ctx.globalAlpha` + a second
additive-ish pass, or just draw the sprite then a semi-transparent orange rect clipped to
it — keep it simple at this resolution).
**Acceptance:** a flame serve into a line of 6 packed shamblers ignites the neighbours, the
fire visibly chains outward, and all 6 die within `burnTime`. No exception thrown (this is
the loop-mutation trap). Frame time stays sane with 14 burning bodies.
---
# 13. Death animation — the player becomes gore
**Requirement:** "Wenn man gefressen wird braucht es eine Endanimation, die zeigt dass man
selbst zu gore wird."
**Cause:** `zombies.js:229` flips `G.mode = 'eaten'` and `render.js:506` simply stops
drawing the player (`if (G.mode !== 'eaten') drawChar(P)`). There is already a small burst
(30 particles + one gore decal) at the eat site, but the summary screen appears immediately
on top of it.
**Build:**
- `G.eatenT` already counts up from 0 (`state.js`). Use it as the animation clock.
- Phase A (0 → ~0.5s): keep drawing the player, but jerking — a violent shake offset and a
reddening tint, with the zombies crowding in. Kill the ball, freeze the score.
- Phase B (~0.5 → 1.4s): the sprite is replaced by an expanding gore burst — reuse
`killZombie`'s machinery (it is the established look): a big `gore` stain, a size-4 `piles`
entry at `P.x/P.z`, and ~90 particles with a wide `vy` spread. Add `G.shake` and one
`boomFlash` frame for the impact.
- Phase C (from ~1.6s): the existing summary fades in (`render.js:559`). Gate
`TAP TO PLAY AGAIN` on `G.eatenT > 2.2` instead of `0.7` so the animation is not skipped
by a panic tap.
- The pile stays visible under the summary scrim — that is the point: you are the biggest
gore mound on the court.
**Acceptance:** get eaten; screenshot at eatenT ≈ 0.3 / 0.9 / 2.0 and confirm three distinct
stages. Restart still works.
---
# 14. Longer changeover, and the ball boys get a mop
**Requirement:** "changeover sollte etwas länger dauern. die balljungen, die die reinigung
machen brauchen einen mop."
**Cause:** `config.js:33` `changeoverT: 4.5`. `drawBoy()` (`render.js:136`) uses
`x_boyrun1..4` — a plain 4-frame run cycle, no prop.
**Build:**
- `changeoverT: 7.0`. The changeover already suppresses spawns and serving
(`zombies.js` spawn gate, `keyPressed` guard), so a longer one is a real breather. Check
it does not feel dead — if it does, have more boys cross.
- Generate `x_boymop1..4` (Appendix A): the same boy, bent over, pushing a mop, 4 frames of
a push cycle.
- `drawBoy()` picks the sheet by intent. Today `sendBoy()` is used for BOTH "fetch the dead
ball" and "mop this splat" (`state.js:95`, called from `killZombie` with the corpse
position). Add a flag: `sendBoy(tx, tz, mop = false)`, set `mop: true` from `killZombie`
and from `startChangeover`, and select `x_boymop*` vs `x_boyrun*` accordingly. The mopping
boy should also move slower — mopping is not sprinting.
- The gore-cleaning already happens in `updateZombies`'s boy pass (`d.a -= dt * 1.6`); with a
mop sprite that finally reads as *why* the stain fades.
**Acceptance:** trigger a changeover; the boy crosses with the mop sprite, stains visibly
shrink behind him, and the breather runs 7s.
---
# Appendix A — generating sprites and illustrations
The pipeline is documented in `HANDOFF.md` under "Gotchas learned the hard way"; the short
version:
1. Use the `generate-image` skill. It needs `OPENROUTER_API_KEY`, which is already in the
repo's gitignored `.env`. Run generation scripts with a scratchpad venv python
(system python lacks `requests`); the venv is wiped between sessions, so recreate it with
`requests` + `pillow`.
2. Ask for a **magenta background** and, for multi-frame sheets, frames in a row separated by
clear gaps.
3. Slice with the committed `assets/_slice.py`:
`python3 assets/_slice.py sheet.png out1.png out2.png ...` (add `--largest` when frames
touch and stray fragments bridge the gap).
4. Register new names in `render.js`'s `SPR` preload array (~line 71).
Known generator quirks (all from `HANDOFF.md`, all still true):
- It sometimes ignores the magenta instruction — harmless, the key uses the corner colour.
- It often draws near-identical animation frames. Re-prompt with strongly contrasted pose
descriptions.
- Passing the existing sheet as `--input` (edit mode) is the reliable way to match style.
**Do this for every asset here** — `_sheet_brute.png` for the brute wounds,
`_sheet_boyrun.png` for the mop boy — the style must match what is already on court.
Assets this plan needs:
| file(s) | source sheet to match | item |
|---|---|---|
| `z_gw2`, `z_gw3`, `z_gw4` | `_sheet_brute.png` / `_sheet_gwound.png` | 11 |
| `x_boymop1..4` | `_sheet_boyrun.png` | 14 |
| `assets/logo.png` | — (new, standalone) | 10 |
| transition tutorial illustration | — (new, standalone) | 6 |
The two popup illustrations and the logo are **not** chroma-keyed sprites — generate them
with transparent or dark backgrounds sized for their use and skip `_slice.py`.
---
# Appendix B — things to check before declaring done
- `HANDOFF.md`'s benchmarks must still hold: CPU returns 11/12 serves; 30 simulated points
with human-like tapping = 50 hits / 0 whiffs. Items 2, 3 and 11 can all disturb these.
- The settled feel decisions are recorded and must not be regressed silently: the two-stage
serve, the manual wave clear, thin dynamic blood, one-gesture-means-one-thing on touch.
If any item here would break one, flag it to Oliver instead of deciding.
- Update `HANDOFF.md` with anything new that bit you.
- Bump `main.js?v=N` in the final commit so phones get the build.