Temporary file, written because a session ran out of context mid-slice — fold into PROJECT.md and delete when the work lands. Not in the Dockerfile COPY; docs are not served. Records the decision (A: blood-only, measure becomes opponents felled) and the finding behind it: Oliver believed losing a match killed him, when he was bleeding out during deuce wars. That misreading is itself the bug — model A only works if the player can see they are bleeding, not losing. Also carries the build order (summary+measure, named ladder with personalities, wound-driven play, the fall animation, menu, flick teaching, opponent serve games), the flight-recorder recipe that ended four rounds of guesswork, the traps this mode already sprung, and the do-nots. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9.6 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, 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
-
Run summary + the measure (small, do first — it is what makes A legible)
- Track and display opponents felled, not
G.runGames. TodaymatchGameWonbumpsG.runGames(beats.js:308) because every mode uses it as the ladder metric; the match needs its own count (G.match.opp - 1already is it) surfaced on the death screen and in a match-specific localStorage best (drillBestis the pattern to copy). - Death screen should speak tennis: "FELLED 3 OPPONENTS · 14 GAMES".
killPlayeralready owns the single death path and the run summary. - While here: make the blood legible so A reads correctly. The vignette exists (four linear edge fades, sqrt curve) — check on a phone whether hits 1-2 of 8 land hard enough.
- Track and display opponents felled, not
-
Named opponent ladder (medium, mostly data)
BOSS_ARTalready holds ten named designs with wound stages and throw frames — GROUNDSKEEPER, LINE JUDGE, CHAIR UMPIRE, HEAD COACH, PHYSIO, SPONSOR MASCOT, THE CHAMPION at ten. Rung N gets a name plate and a personality row.- Personalities are the point, not the names: serve-and-volley, drop-shot merchant, body-shot
thrower — note
shots[]and the PARRY are fully built and completely unused in the match. That is the cheapest depth available anywhere in this mode.
-
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
drawZombieandbleed()do (never literal hp). -
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 design throughassets/_regen.py, not a JS rotate/sink. -
Menu entry (small) — the three-mode select Oliver planned: CLASSIC TENNIS / ZOMBIE TENNIS / SPLATTER GORE, replacing the back-room door for this mode.
-
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. -
Opponent serve games (bigger, later) — the zombie never serves today. Alternating service is real tennis and doubles the skill surface (return timing).
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:
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)
autoAssistis 0 onpointer: fine. The desktop pane has no auto-run. The match floors it withmatchAssist, andassistK()is shared by the mover ANDintercept— computing reachability with the raw knob made the planner answer "run home" forever.- The contact chain is three separate things: LEGS (
matchAssist) → DESTINATION (assistKinintercept, plus theanyHitshort-ball candidate that chases dying rollers) → REACH (matchReach). Each was a separate defect found in a separate recorded session. ball.lastHitmust be stamped bytossBall. 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.serveNumis only reset by tennis'spointTo, 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.jsfuzzes; 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. BumpSPRVtoo if sprites change.
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
drillOneShoton as a default — it skips everyZWOUNDstage, so the roster's best art never renders. - Touch the gore mode's contact/recovery rules while tuning the match.
matchReachand 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.