Files
splatter-tennis/HANDOFF-MATCH.md
T
wuwiandClaude Opus 5 e674677f4e PROJECT.md: the art pipeline, corrected — and the brute stays parked
A day went into sprite consistency and most of it was spent solving the problem
in the wrong place. Written down so the next session starts from the answer
instead of rediscovering it.

The boundary everything else follows:
  the generator, GIVEN REFERENCE IMAGES  ->  identity, pose, composition
  the pipeline, DETERMINISTICALLY        ->  exact colour
  the gates                              ->  layout accidents only

With the proof, because it is the part that is hard to believe until measured:
the brute's serve sheet came back with his build, his garments and his DARK
TONES pixel-identical to the reference and his big pale shirt two shades
brighter. The model was looking straight at the character. Every call
re-renders and re-rendering re-lights. No prompt fixes that.

And the rule underneath all of it — A REFERENCE BEATS A DESCRIPTION, at every
level. The racket zombie's sheet was made from the character as an IMAGE plus
the poses as IMAGES and landed first time; the brute's three sheets each
re-derived the character from the same paragraph and produced three different
brutes. Corollary worth having: every corrective sentence in a prompt is
compensating for a reference you did not supply. When a prompt grows a third
"do NOT" clause, add an image instead.

Also written down: correct-then-check, calibrate-before-you-gate (with the
numbers every threshold was calibrated on), the five gates and what each one
actually measures, six traps that each shipped something silently before they
were caught, and three things still open — including that `changed` passed a
headless brute that still had its head, because it knows something moved and
not what.

The brute is PARKED: out of ZFAMS, 31 frames on disk, nine batches in the JSON,
one line away from live. Its healthy eleven are good; its wound stages are not.

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

224 lines
13 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, lost game drops a rung. 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 ladder**~~ **DONE, partly.** Rung N takes design N's name off the chapter
roster (`matchRung` in config) plus a personality that is DIALS — pace, width, flub, speed
scalings on numbers `racketAI` already reads. Name plate on the walk-on, board names it
(`short`), kills file under the rung so THE LADDER screen can list them.
**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 / THE LADDER / 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 rung, 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.