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:
1 parent
553b765846
commit
99b2dec3bf
2 files changed
+167
-316
No files matched your search
@@ -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 <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.
|
||||
+167
@@ -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
|
||||
|
||||
Reference in new issue
Block a user