Files
splatter-tennis/HANDOFF-MATCH.md
T
wuwiandClaude Opus 5 52b38e4a39 Eight bodies on the card, and the umpire gets his voice back
THE FLICK, again. "i can barely reach the extremes of the court." It could
reach 3.3 m against a 4.115 m line — safe, and too safe. TUNE.flickWidth is
3.95 now (96% of the way to the corner, 17 cm to spare) with flickReach 0.42,
which keeps the guarantee intact: reach x timing + scatter stays inside the
line at EVERY quality, worst case 4.06. 20% more reach, still impossible to
send out by aiming.

A FORWARD FLICK IS A SMASH. Up-screen is the far court, so flicking into it
asked for depth and nothing else — a direction with no consequence behind it.
It buys pace now (smashBoost, scaled by timing). Deliberately NOT distance:
solveShot still lands it on the same spot, so the shot arrives flatter and
sooner rather than longer. A smash you can hit out on a whim is just the old
bug wearing a hat.

THE FALL HOLDS. "the opponent should fall and then lie there for a quick
moment, the player stands up too quick." fall3 goes 0.30 -> 0.62. A corpse
that touches the ground and is instantly upright reads as a stumble.

THE CARD. Ten opponents, six of them NEW bodies off the one-sheet pipeline
(shambler, ball boy, steward, runner, groundskeeper, line judge), with the
club pro's own body and the brute woven in as 7 and 8. It is a ladder of
STAFF: the further you get, the further up the club the thing across the net
used to be. Narrative and difficulty are the same list read twice, and a test
walks all ten — whiff must fall every rung, names must fit the board's 8-char
slot, speed may zigzag by character but never regress.

Steeper per rung (speedPer .08->.11, pacePer .035->.05, flubKeep .82->.76):
"in general this is too easy now". With aimed returns no longer sailing out,
the player stopped handing over free points and the ladder had to earn them
back. The opening got SOFTER at the same time — 0.72 where the old first
opponent was 0.85.

THE UMPIRE WAS MUTED IN THIS MODE and nobody noticed. aside() refused during
any interlude; the match runs on a permanent one. So the single mode that is
literally a tennis match he is sitting over was the one mode he never spoke
in. Same bug shape as sendBoy, same fix. Six dry lines via say -v Daniel: two
on taking a limb, two on your blood, one when there is nearly none left, one
on felling the thing. asideEvery still gates him.

...and sendBoy had the identical bug, so the mop boy now comes out between
games to the FAR court, where one body has been bleeding through four wound
stages. Between games only, never mid-point.

The line judge took two rolls: the first cut to 6 figures in its last row and
_zsheet.py refused it rather than mis-slicing. That refusal is the feature.

59 tests green. Verified in the browser: SHAMBLER on court in its own body.

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

19 KiB

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 DONE (2026-08-12). Point won → the body COLLAPSES → drags itself up minus the limb. beats.tickMatchFall, on its OWN clock beside the horde's warning (a beat that moves the phase under a latch strands it — the mgT lock, again); the 'over' beat it also sets is only there to keep the court quiet and is deliberately set to OUTLIVE the fall, so it cannot expire into setupPoint() mid-collapse and hand the player a serve over a body on the ground. The contract, and the whole point of the beat: the three collapse frames wear the stage it STILL WAS, the rise wears the stage it has BECOME. Frames are named through zb.showFrame (the bench's own override, which already degrades a missing stage to the healthy frame). Only when the stage actually moved — a quarter off a full pool is 0.75, so point one crosses nothing and a second of collapse for nothing is a beat the player waits out.

  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 DONE (2026-08-12), and it was almost entirely switched off rather than missing. setupPoint's tennis branch already placed a non-player server behind its own baseline and armed G.aiServeT; update() already tossed and struck for O. Both were gated on G.mode === 'tennis', and the match runs in splatter under a permanent interlude — one condition. What was genuinely new: service alternates at each game boundary (the match calls advancePoint directly, and the flip lives in tennis's pointTo, which the match bypasses — the same shape as the G.serveNum bug above), the zombie takes O's mark when it serves so the picture and the ballistics agree on where it stands, and the serve POSES are named onto it (bounce → toss → serve, held briefly after contact) because O is invisible here. Known simplification: the interlude's line-call branch has no serve box and no second serve, so a missed opponent serve is simply the player's point. Add faults when it matters.


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 (gore / zombie tennis / classic; 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:

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.

THE FLICK WAS LOSING THE POINT (fixed 2026-08-12)

Oliver: "when returning a ball with a spin on the screen that seems to be too sensitive, 8/10 times the ball lands out." It was arithmetic, not feel. The singles line is 4.115 m. An aimed shot targeted a flat ax * 3.3 and THEN added err() on top — up to ±aimError (2.4 m) at quality 0 — so a flicked return landed as wide as 5.7 m, and the only clamp in the path was at ±5, outside the court. An UNAIMED return targets 0 + the same scatter and is therefore always in. Aiming was the thing that lost the point, which is exactly backwards for the mode's one act of skill.

TUNE.flickReach (0.45) scales the aim width by the same quality the scatter shrinks with, so the two can never sum past the line: worst case 1.49 + 2.4 = 3.89 at quality 0, and a perfectly timed flick still gets the full 3.3 with no scatter left on it. A test asserts that sum across the whole quality range rather than sampling rallies. Not named aimReach — that key already exists further down TUNE (the slingshot's aim-ray length, 22 m) and the duplicate silently won, which a test caught.

The placement ring carries q now and reads as the flick's receipt: tight and hot when your timing earned the full width, wide and dim when you barely got a racket on it. Without it the new rule is invisible — the shot simply goes less wide and nothing says why.

THE CARD — eight bodies, one ladder (2026-08-12)

config.OPPONENTS is the narrative AND the difficulty curve, and they are the same list read twice: the further you get, the further up the club's hierarchy the corpse across the net used to be. SHAMBLER, BALL BOY, GROUNDSKEEPER, STEWARD, RUNNER, LINE JUDGE, CLUB PRO, THE BULK, DIRECTOR, CHAMPION. Six of those wear NEW sprite families generated through the one-sheet pipeline (sh bb st ru gk lj), the CLUB PRO is the original z_r* body and THE BULK is the brute.

The ramp is asserted, not assumed — a test walks all ten and requires the whiff rate to fall every single rung (1.35 -> 0.5 on the trait, on top of matchFlubKeep), the names/short to be complete and to fit the board's 8-char slot, and speed never to step meaningfully backwards. Speed is allowed to ZIGZAG by character (5 is the RUNNER, 6 paints lines instead of sprinting); the whiff rate is the dial the ramp actually rides on.

Per-rung growth was steepened at the same time (matchSpeedPer .08->.11, matchOppPacePer .035->.05, matchFlubKeep .82->.76): "in general this is too easy now". The flick fix means the player no longer hands over free points by sailing returns out, so the ladder had to earn them back. The OPENING is deliberately softer than before — the shambler starts at 0.72 where the old first opponent was 0.85. Both ends moved.

The umpire finally speaks in this mode. aside() refused during any interlude and the match runs on a permanent one, so he was silently muted for the entire mode — the one mode that is literally a tennis match he is sitting over. Same shape as sendBoy, fixed the same way (G.interlude && !G.match). Six new dry lines through the existing say -v Daniel -r 168 pipeline: two on taking a limb, two on your blood, one when your blood is nearly gone, one on felling the thing. The shared asideEvery cooldown still holds, which is what keeps him situational rather than chatty.

And the mop boy comes out between games, to the FAR court (sendBoy(0, 4.5, true) from matchGameWon) — that is where one body has been bleeding through four wound stages, and it keeps him out of the near court where you are about to serve. Between games only; never mid-point.

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

A new opponent FAMILY is now one API call and one script — read PROJECT.md, "THE ZOMBIE FAMILY PIPELINE" before generating one. The one-line version: ask for all 31 figures in a single sheet (one roll, one character, by construction), poses from an existing family and identity from one reference frame with each image's JOB named in the prompt, then assets/_zsheet.py cuts it back down with ONE global scale and ONE global palette.

The older nine-batch flow (_regen.py, _master.py) is still right for single sheets, remasters and the boss roster, and PROJECT.md, "THE ART PIPELINE, corrected" still governs it. Run python3 assets/_regen.py --verify <batches> before trusting any chained batch: it is what proved 27 of the parked brute's 31 frames were never art.

The match opponent's own art is complete — 31 frames, three wound stages, the serve and the fall — and the BRUTE is live beside it (ZFAMS has 'br', the KEEPER wears it), generated by the new path.

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.