diff --git a/HANDOFF-MATCH.md b/HANDOFF-MATCH.md deleted file mode 100644 index 73fa12d..0000000 --- a/HANDOFF-MATCH.md +++ /dev/null @@ -1,316 +0,0 @@ -# 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:** `shots[]` and the PARRY are fully built and completely unused here.~~ - **SCRATCHED** (2026-08-17, Oliver): a body-shot thrower in the match is not wanted. Both - mechanics stay where they are; do not revive this without him asking. - -3. ~~**Wound-driven play**~~ **DONE** (2026-08-17). Two dials, both read through `racketStage` — - the very call the SKINS are picked with, so what you watch it lose is what it loses: - `config.matchWound` returns `{reach, flub}` and `racketAI` spends it on its contact radius and - its whiff rate. **Deliberately small** ("not too pronounced"): ~6% reach and ~17.5% more whiff - PER STAGE, so at headless it is 12% and 35% — texture under the ladder, never a second - difficulty curve, and a test pins that ceiling so a later tuner cannot quietly cross it. - 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 changes nothing** — the dials move on points two and three, with the art. - -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**~~ **DONE** (2026-08-17). The ghost-hand lesson for the PLACEMENT FLICK, in the - same register as PULL DOWN and SWIPE TO DODGE — because it is now literally the same demo: the - apron mime, played in the SERVE STANCE after a beat of standing still, mimed both ways in turn so - it never teaches one corner. **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, over the shoulder, at the moment the player has the least attention to spare. - The words carry both halves — SWIPE AS YOU HIT TO AIM — because WHEN alone teaches nothing and - WHAT FOR alone gets tried at the wrong moment and read as broken. - It rides the failure it answers — `G.flatStreak`, returns that left with no aim on them - (`flatCount`, reset by the first aimed one) — and only after `TUNE.flickHintAfter` of them, - because hitting flat is a legal shot and not a mistake. **One lesson at a time:** the tap hint - wins if both qualify, since teaching placement to someone who cannot reach the ball is the wrong - lesson. Touch only (on a keyboard the aim is held keys, and SWIPE would name a gesture that does - not exist there). **It belongs to the RALLY MODES and only those — classic and zombie tennis — - which is what `rallyFeel()` already means.** Not gore, and not gore's OPENER: `G.mode === - 'tennis'` covers classic AND that prologue, which is the wrong seam (Oliver, 2026-08-17: "if it - is in gore mode this would be the wrong place"). Note the match is not mode `'tennis'` at all — - it plays under splatter's permanent interlude — so the mode check alone would have missed the - very mode it was built for, in both directions at once. - The words sit ABOVE the stroke: below them is the baseline, and 10px type over that white line - is unreadable. - -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 -(`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. - ---- - -## 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 ` 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. diff --git a/PROJECT.md b/PROJECT.md index e67d9ab..fd06493 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -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