Files
splatter-tennis/HANDOFF-MATCH.md
T
wuwiandClaude Opus 5 a568d75aed Clear the last of the "ladder" vocabulary out of the handoff and the comments
Left over from the rename: the handoff still described opponent 2 as "rung N"
and pointed at matchRung, and three comments in gameplay.js still called the
opponent curve a ladder. One word per thing, everywhere now — OPPONENTS, WOUND
STAGES, a sprite FAMILY — with a note in the handoff saying which word replaced
what, so the next reader is not left translating.

(The barrel weapons keep their own "rungs of the same ladder" line: that one is
about the WEAPON payout order and was never part of the confusion.)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 14:54:54 +02:00

226 lines
14 KiB
Markdown

# HANDOFF — ZOMBIE TENNIS, making it a real mode
**TEMPORARY FILE.** Written 2026-08-12 because a session ran out of context mid-slice. When the work
below is done, fold anything durable into `PROJECT.md` (the only planning document) and **delete
this file**. It is deliberately not in the Dockerfile COPY — docs are not served.
**Read `PROJECT.md` first, as always** — especially "How to work on this", the invariants, and the
drill/match session logs (passes 13-23), which carry the reasoning behind every number named here.
---
## Where things stand
Zombie tennis is a **back-room prototype that plays well on a real phone** ("big improvement on real
mobile"). Entry: splash → five taps in the top-left corner (`#hot`) → **MATCH TEST**, or `#match`.
Sibling prototypes there: WEAPON TEST, BOSS TEST, DRILL TEST (the abandoned cognitive mode — see
below). 49 tests green, seeds clean. Last commit `9b9f18d`, `index.html` at `?v=176`.
**What it is:** real tennis against the racket zombie. One game per opponent, tap-and-timing
controls, the deuce/ad serve positions, and **the score is the body** — each point you win takes a
quarter of its pool and walks it through its wound stages; the game-winning point fells it and a
fresh opponent shambles on. Its points draw your blood (`matchPlayerHp` 8, `matchHeal` 2 per game
won). It runs on a PERMANENT interlude — `G.interlude` is what makes serving tennis-like, hides the
rack and wakes `racketAI` — and the only change is in `resolveInterlude`, which feeds `matchPoint`
instead of ending anything. All of it is ~120 lines in `beats.js`.
**The ladder** escalates in `racketAI`, not hp: speed (`matchSpeedBase`/`Per`), whiff
(`matchFlub`/`FlubKeep`), width (`matchWideBase`/`WidePer`), pace (`matchOppPace`/`PacePer`). Player
side: `matchAssist`, `matchReach`.
---
## THE DECISION JUST MADE — scoring model A
Oliver picked **A: blood-only**, of three options offered:
- **A (chosen)** — the run is one long bleed across opponents; losing a game costs nothing but the
blood already spent, and heals are how you last. **The measure becomes OPPONENTS FELLED.**
- B — tennis-true: a lost game ends the run. Rejected for now (worth one playtest someday; it might
be the mode's real personality).
- C — hybrid, a lost game drops you an opponent. Rejected.
**Important context for whoever implements this:** Oliver believed losing a match killed him
("i really die losing one match"). It does not — he was bleeding out during deuce wars and hitting
zero at their game point. **That misreading is itself the bug to fix**: the mode is not
communicating its own rules. Model A only works if the player can see they are bleeding, not losing.
---
## The build slice, in order
1. ~~**Run summary + the measure**~~ **DONE** (2026-08-12, staging). The measure is OPPONENTS
FELLED, in play (`OPPONENT n · m FELLED` under the board) and on the death screen (`BLED OUT.` /
`FELLED n OPPONENTS · m GAMES`). No second localStorage key: a match run is banked through the
same `endRun` with a mode tag — see "The record shape" below. The blood also got a readout, one
pip per hit left beside the board, because the vignette says "worse" and never says "how many".
2. ~~**Named opponent list**~~ **DONE, partly.** Opponent N takes design N's name off the chapter
roster (`matchFoe` in config, rows in `config.OPPONENTS`) plus a personality that is DIALS — pace,
width, flub, speed scalings on numbers `racketAI` already reads. Name plate on the walk-on, the
board names it (`short`), and the kill files under it so the OPPONENTS FELLED screen can list it.
*(The word "ladder" was retired — it had come to mean the opponent list, the wound stages AND a
stats screen. One word per thing now: OPPONENTS, WOUND STAGES, and a sprite FAMILY.)*
**Still open, and still the cheapest depth in the mode:** `shots[]` and the PARRY are fully built
and completely unused here. A body-shot thrower is a personality no dial can express.
3. **Wound-driven play** *(small, big payoff)* — the injuries should MEAN something: a one-armed
opponent loses reach, a headless one flubs more. Score-is-the-body becomes score-changes-the-game.
Read the fraction the way `drawZombie` and `bleed()` do (never literal hp).
*The art is now there for it:* every wound stage has its own ready stance, shuffle pair and
forehand (`z_rw1*`, `z_rw2*`), so a wounded opponent still plays rather than reverting to a walk.
4. **The fall animation** *(art pass — Oliver's idea, and a good one)* — point lost → the corpse
COLLAPSES → drags itself up minus the limb. It explains the wound stage popping between points and
gives every point a payoff beat. `matchReady()` is the natural slot. **Constraint: draw frames,
do not fake them** (settled rule) — this needs a small fall/rise sheet per stage through
`assets/_regen.py`, not a JS rotate/sink. The `racket_*` batches are the template.
5. ~~**Menu entry**~~ **DONE.** Three modes on the setup screen, from `config.MODES`. The back room
keeps its door (MATCH TEST still works) — the menu did not replace it, it just stopped being the
only way in.
6. **Teaching** *(small)* — a ghost-hand lesson for the PLACEMENT FLICK. It is the counter-attack
(flick during the swing aims the return; wrong-footing is how points are won) and it is taught
nowhere on touch. The placement ring (`G.placeMark`) already shows the result; nothing teaches
the cause.
7. **Opponent serve games** *(bigger, later)* — the zombie never serves today. Alternating service
is real tennis and doubles the skill surface (return timing).
---
## What the 2026-08-12 session added beyond the slice
**CLASSIC TENNIS is a real mode, not the opener with the turn switched off.** Oliver: *"if the core
tennis is good everything else is good."* Six games with a two-game lead is a set,
`TUNE.classicSets` is the match, no tiebreak (marked `ponytail:` — add it when a real match actually
stalls at 6-6). The CPU stops rubber-banding there: `aiFlubLoss` exists to get a new player PAST the
opener and would have him in pieces by the second set.
**A second ENDING, with no corpse in it.** `G.done = {won, t}` — same summary and gated tap as the
death screen, its own words, the man still standing. All three endings share one render block; three
copies of it would drift the first time one moved.
**THE RECORD SHAPE — read this before touching stats.js.** One character `m` on the run record
(`g`ore / `z`ombie tennis / `c`lassic; absent means gore, so no migration). The record's two headline
slots carry each MODE's own two numbers:
| mode | `g` | `s` | `dead` |
|---|---|---|---|
| gore | games survived | sets banked | died |
| zombie tennis | **opponents felled** | games played | died |
| classic | games played | sets won | **match LOST** |
`g` is what everything ranks on, so each mode's real measure goes in that slot and there is no second
best stored anywhere to drift. `read(period, runs, mode)` filters; the stats screen is one function
with three sets of words (BODY COUNT / OPPONENTS FELLED / THE RECORD). The world board stays the gore
mode's — it ranks kills in a run, and a five-body match would post a "5 kills" row next to a
300-kill massacre. A tennis board would rank FELLED with points won as the tiebreak; that is a
server change.
**THE MATCH OPPONENT HAS ITS OWN SPRITES** (`z_r*`), drawn off the living opponent's poses: ready
stance, shuffle pair, forehand, at each wound stage, both tours. `z_c*` (the turned CPU) is
untouched and keeps its shamble — in the gore mode shambling is the right thing for it to do. The
frame is picked off `racketAI`'s own `pose`, so the picture cannot drift from the behaviour.
Expansion ideas that survived discussion, in `PROJECT.md`'s LATER: the **tournament frame**
(QUALIFYING → ROUND OF 16 → THE FINAL vs THE CHAMPION — gives the run a destination), the Hawk-Eye
**challenge** as an earned continue, sudden-death deuce past a given opponent, doubles, and **item 7:
multiplayer** (ghost match first).
---
## THE TOOL THAT MATTERS: the flight recorder
Four rounds of "still the same behaviour" were ended by watching Oliver play, not by measuring with
an autopilot. **Autopilot measurement lied for three passes straight** because the autopilot set
`autoAssist` by hand and therefore had legs the real player did not.
Recipe — inject into the Browser pane, let him play, then read it:
```js
window.__rec = { losses: [], wins: [], trail: [] };
let lastPts = '0-0', lastHp = null;
setInterval(() => {
const T = window.T; if (!T || !T.G.match) return;
const G = T.G, ball = T.ball, P = T.P;
window.__rec.trail.push({ pts: T.score.pts.join('-'), phase: G.phase, hp: G.hp,
bx: +ball.x.toFixed(1), bz: +ball.z.toFixed(1), bAct: ball.active, bLast: ball.lastHit,
px: +P.x.toFixed(1), pz: +P.z.toFixed(1), swing: P.swingT >= 0 });
if (window.__rec.trail.length > 100) window.__rec.trail.shift();
const pts = T.score.pts.join('-');
if (pts !== lastPts) {
const wasO = pts.split('-')[1] !== lastPts.split('-')[1] || (pts === '0-0' && G.hp < (lastHp ?? G.hp));
(wasO ? window.__rec.losses : window.__rec.wins).push({ at: pts, from: lastPts, trail: window.__rec.trail.slice(-30) });
lastPts = pts;
}
lastHp = G.hp;
}, 16);
```
Then classify each loss by: minimum player↔ball gap, whether he swung at closest approach, how far
he ran, where the ball died. That triple diagnosed every defect in the contact chain.
- **Sample at 60 Hz, not 20** — a 20 Hz sampler overstates closest approach by up to ~0.5 m on a
20 m/s ball, which is the entire size of the effect being measured.
- It is passive. Do not "help" the player from inside it.
- A page reload wipes it. Re-arm after every module refresh.
---
## Traps this mode has already sprung (do not re-learn these)
- **`autoAssist` is 0 on `pointer: fine`.** The desktop pane has no auto-run. The match floors it
with `matchAssist`, and `assistK()` is shared by the mover AND `intercept` — computing
reachability with the raw knob made the planner answer "run home" forever.
- **The contact chain is three separate things**: LEGS (`matchAssist`) → DESTINATION (`assistK` in
`intercept`, plus the `anyHit` short-ball candidate that chases dying rollers) → REACH
(`matchReach`). Each was a separate defect found in a separate recorded session.
- **`ball.lastHit` must be stamped by `tossBall`.** A stale `'O'` on a toss from behind the baseline
read as the opponent's passing shot: a point and blood PER TAP. Fixed, but the shape recurs —
anything that revives the ball must own it.
- **`G.serveNum` is only reset by tennis's `pointTo`**, which the match bypasses. `matchReady()`
resets it; a new mode with its own point boundary must too.
- **A 20 Hz snapshot of a population that legitimately empties is not a measurement** (this cost a
flaky test that blocked the pipeline). The suite is **seeded** — `SEED=1234 node test.js` fuzzes;
a fixed seed can silently turn a guarded test into a no-op, so check guards still run.
- **`?v=` in index.html on every push, or phones keep the old build.** Bump `SPRV` too if sprites
change.
- **`stats.banked` is only cleared by a KILL**, and classic tennis has none — so the second and every
later classic match of a session was silently swallowed by the double-bank guard. `beginRun()` is
the reset now and `restartRun` calls it. Any future mode with no kills in it needs that path.
- **`_fine.py` used to key EVERY magenta pixel, not just the background** — and the generator paints
racket strings in the backdrop colour more often than not, so sheet after sheet installed a body
holding an empty hoop. Fixed (flood from the border; interior magenta is repainted pale grey), but
check a new sheet's rackets anyway. While fixing it: `Image.fromarray` images are READ-ONLY and
PIL's `floodfill` swallows the write error and returns having filled nothing.
- **The regen detail bar RATCHETS against the file being replaced.** A repose of art that is already
fine (`check: 'same'`) can be rejected for not out-detailing a redraw; `'loose'` is the right bar
for that job, and resetting the placeholder to the coarse original resets the bar.
## The art pipeline
Everything learned generating this mode's sprites is in **PROJECT.md, "THE ART PIPELINE, corrected"**
— read it before touching `assets/_regen.py`, `_fine.py` or `_master.py`. The one-line version: the
generator does identity and pose given REFERENCE IMAGES, the pipeline does exact colour with
arithmetic, and the gates only catch layout accidents. A day was spent discovering that the middle
one is not a prompting problem.
The match opponent's own art is complete — 33 frames, three wound stages, the serve and the fall —
and `assets/_master.py` turns that pose set into the master sheets a new opponent is restyled from.
## Do not
- Re-add regicide to the match (killing the body with a smash) — removed deliberately; it made every
hard serve a free unaimed kill and smeared the score-is-the-body relationship.
- Turn `drillOneShot` on as a default — it skips every `ZWOUND` stage, so the roster's best art never
renders.
- Touch the gore mode's contact/recovery rules while tuning the match. `matchReach` and the
centre-recovery are match-only ON PURPOSE; gore splatter's tight contact and depth-only recovery
protect the sidestep.
- Revive the TRIAGE DRILL without a new idea. It is still in the back room and it works exactly as
designed, but it failed the only test that matters: *"i think the normal player does not grasp
this."* Four designs died learning that **an invented rule needs teaching; the rules a scene
already implies do not.** That is why the match is tennis.