The gate said `G.mode === 'tennis' || G.match`, and mode 'tennis' is two different things: classic, and the single prologue game a gore run plays before the court turns. Placement is not the skill that prologue is asking for. rallyFeel() already draws exactly the line wanted — classic and zombie tennis, the two modes that ARE rallies — so the hint rides that instead of a mode string. Verified both ways on a phone-sized preview: the lesson beside the player in zombie tennis, and gone under gore-opener conditions with everything else identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
312 lines
20 KiB
Markdown
312 lines
20 KiB
Markdown
# 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 TAP AS IT ARRIVES: a dotted track out of the player, the hand
|
|
riding it, SWIPE AS YOU HIT, mimed both ways in turn so it never teaches one corner.
|
|
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.
|