The match handoff folds into the only planning document, and goes

HANDOFF-MATCH.md was always temporary — written when a session ran out of
context mid-slice, and carrying its own instruction to fold into PROJECT.md and
be deleted once the slice was done. The slice is done, so it is.

What crossed over is the contract, not the diary: what the mode is, the
blood-only scoring and the misreading that forced the blood readout, the four
ladder dials, the card of ten, the wound dials, the fall's frame contract, the
flick arithmetic and its lesson, the umpire and the boy, the opponent's serve
games, classic as its own mode, the record shape, the traps, and the do-nots
(now including the scratched body-shot thrower). The reasoning behind the
numbers already lived in the drill/match session logs and was not copied twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-17 08:54:38 +02:00
1 parent 553b765846
commit 99b2dec3bf
2 files changed
+167 -316

No files matched your search

+167
View File
@@ -414,6 +414,173 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu
`RETURNED`, `DOUBLE KILL`) is a footnote to something you already watched happen. Sharing the loud
FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone.
## ZOMBIE TENNIS — the mode, end to end (folded from HANDOFF-MATCH.md, 2026-08-17)
`HANDOFF-MATCH.md` was a temporary file written when a session ran out of context mid-slice. The
slice is finished; this is what survives it. The reasoning behind the numbers is in the drill/match
session logs (passes 13-23) further down — this section is the contract, not the diary.
**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, and the game-winning point fells it so a
fresh opponent shambles on. Its points draw your blood (`matchPlayerHp` 8, `matchHeal` 2 per game
won). **The measure is OPPONENTS FELLED** — in play (`OPPONENT n · m FELLED` under the board) and on
the death screen. Entry: the setup screen's three modes (`config.MODES`), or the back room's MATCH
TEST, which still works and is the fastest way in for a test.
It runs on a PERMANENT interlude. `G.interlude` is what makes serving tennis-like, hides the rack
and wakes `racketAI`; the only structural change is `resolveInterlude` feeding `matchPoint` instead
of ending anything. **So `G.mode` is `'splatter'` here, never `'tennis'`** — every mode gate written
as `G.mode === 'tennis'` silently excludes the match, and that has been the shape of four separate
bugs (the umpire, the boy, the serve, the flick lesson). `rallyFeel()` is the honest test for "a
real rally mode": classic AND the match, and NOT gore's one-game prologue.
**SCORING IS BLOOD-ONLY** (Oliver's pick of three). The run is one long bleed across opponents;
losing a game costs nothing but the blood already spent, and heals are how you last. *Model B —
tennis-true, a lost game ends the run — was rejected for now and is still worth one playtest; it
might be the mode's real personality.* Context worth keeping: Oliver believed a lost match killed
him ("i really die losing one match"). It did not — he was bleeding out during deuce wars. **That
misreading was itself the bug**: blood got a pip readout beside the board because the vignette says
"worse" and never says "how many".
**The ladder escalates in `racketAI`, not in hp** — the pool is flat. Four dials, each climbing per
rung: speed (`matchSpeedBase`/`Per`), whiff (`matchFlub`/`FlubKeep`), width (`matchWideBase`/
`WidePer`), pace (`matchOppPace`/`PacePer`). Player side: `matchAssist`, `matchReach`.
**THE CARD — ten bodies, one list read twice.** `config.OPPONENTS` is the narrative AND the
difficulty curve: 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 — each with a name, a `short` for the board, a line, a sprite family and a
personality that is DIALS (pace, width, flub, speed scalings on the numbers `racketAI` already
reads). **The ramp is asserted, not assumed**: a test walks all ten and requires the whiff rate to
fall every rung, the names to fit the board's 8-char slot, and speed never to step meaningfully
backwards. Speed may ZIGZAG by character (5 is the RUNNER, 6 paints lines instead of sprinting); the
whiff rate is the dial the ramp actually rides on.
**THE WOUNDS PLAY** (2026-08-17). `config.matchWound` reads the same `racketStage` the SKINS are
picked with and returns `{reach, flub}`, which `racketAI` spends on its contact radius and its whiff
rate: a one-armed opponent covers less, a headless one misses more. Deliberately SMALL — 6% reach
and 17.5% whiff PER STAGE (Oliver: "not too pronounced") — texture under the ladder, never a second
difficulty curve, and a test pins that ceiling. Unhurt returns exactly `{1, 1}` and the whole thing
is gated on `G.match`, which is what keeps the gore duel's racket zombie untouched. Note the
arithmetic: a quarter off a full pool is 0.75, so **point one crosses no stage** — the dials move on
points two and three, in step with the art.
**THE FALL.** 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 deliberately longer than the fall, so it cannot
expire into `setupPoint()` mid-collapse and hand you a serve over a body on the ground. The
contract: **the three collapse frames wear the stage it STILL WAS, the rise wears the stage it has
BECOME**, and it only plays when the stage actually moved.
**THE FLICK IS THE MODE'S ONE ACT OF SKILL, and it used to lose the point.** Oliver: "8/10 times the
ball lands out". It was arithmetic. The singles line is 4.115 m; an aimed shot targeted a flat width
and THEN added `err()` on top — up to ±`aimError` at quality 0 — so a flicked return landed as wide
as 5.7 m, while an UNAIMED return (target 0 + the same scatter) was always in. **Aiming was the thing
that lost the point.** `TUNE.flickReach` scales the aim width by the same quality the scatter shrinks
with, so the two can never sum past the line, and 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) and the duplicate silently won, which a test caught. The placement
ring carries `q` 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.
**...and it is TAUGHT** (2026-08-17), because the ring is a receipt, not a lesson — you only get one
if you already knew to swipe. The ghost hand mimes it on the apron in the SERVE STANCE, in the very
demo the slingshot lesson plays, armed by a run of returns that left with no aim on them
(`G.flatStreak` / `flatCount`, `TUNE.flickHintAfter`) and disarmed by the first aimed one. **A
gesture is read in a pause, not in a point** (Oliver: "it is displayed mid-play.. please rather show
it in one of the pauses") — the first cut rode the incoming ball, at the moment the player has the
least attention to spare. Touch only, rally modes only, and the tap lesson wins if both qualify:
teaching placement to someone who cannot reach the ball is the wrong lesson.
**The umpire and the boy.** `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 (`G.interlude && !G.match` now, the same shape as `sendBoy`). Six dry lines: two on
taking a limb, two on your blood, one when it is nearly gone, one on felling the thing, all behind
the shared `asideEvery` cooldown. **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.
**Opponent serve games** were almost entirely switched off rather than missing: `setupPoint`'s
tennis branch already placed a non-player server and armed `G.aiServeT`, and `update()` already
tossed and struck for O — both gated on `G.mode === 'tennis'`. 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` trap below), the
zombie takes O's mark when it serves so the picture and the ballistics agree, and the serve POSES are
named onto it (`bounce` → `toss` → `serve`). *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.
**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 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. It has its OWN ENDING with no corpse in it (`G.done = {won, t}`);
all three endings share one render block, because three copies 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. 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 sprites.** The match opponent has its OWN art (`z_r*` and the nine families beside it), drawn
off the LIVING opponent's poses: ready stance, shuffle pair, forehand, at each wound stage, both
tours, plus the serve and the fall. The frame is picked off `racketAI`'s own `pose`, so the picture
cannot drift from the behaviour. `z_c*` (the turned CPU) is untouched and keeps its shamble — in the
gore mode shambling is the right thing for it to do.
### 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 mode gate written as `G.mode === 'tennis'` excludes the match** (it is `'splatter'` under a
permanent interlude) and INCLUDES gore's prologue. Reach for `G.match` / `rallyFeel()` instead.
- **`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. Any future mode with no kills in it needs that 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.
- 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.
- Put a body-shot thrower in the match. `shots[]` and the PARRY are built and unused here, and it
looked like the cheapest depth in the mode — **Oliver scratched it, 2026-08-17.** Do not revive it
without him asking.
- Turn `drillOneShot` on as a default — it skips every `ZWOUND` stage, so the roster's best art never
renders.
- Revive the TRIAGE DRILL without a new idea. It still works exactly as designed and 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.
**Expansion ideas that survived discussion** (also in LATER): the tournament frame (QUALIFYING →
ROUND OF 16 → THE FINAL vs THE CHAMPION), the Hawk-Eye challenge as an earned continue, sudden-death
deuce past a given opponent, doubles, and multiplayer.
## Session log: a lane is free when its body is gone, not when it is far away (2026-08-16)
A seed-sensitive red suite (`SEED=1234 node test.js` failed on the drill's "spaced *after the walk