HANDOFF-MATCH.md: scoring model A, and the slice to make the match a real mode
Temporary file, written because a session ran out of context mid-slice — fold into PROJECT.md and delete when the work lands. Not in the Dockerfile COPY; docs are not served. Records the decision (A: blood-only, measure becomes opponents felled) and the finding behind it: Oliver believed losing a match killed him, when he was bleeding out during deuce wars. That misreading is itself the bug — model A only works if the player can see they are bleeding, not losing. Also carries the build order (summary+measure, named ladder with personalities, wound-driven play, the fall animation, menu, flick teaching, opponent serve games), the flight-recorder recipe that ended four rounds of guesswork, the traps this mode already sprung, and the do-nots. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9b9f18d312
commit
a54cfa5f4f
1 file changed
+167
@@ -0,0 +1,167 @@
|
||||
# HANDOFF — ZOMBIE TENNIS, making it a real mode
|
||||
|
||||
**TEMPORARY FILE.** Written 2026-08-12 because a session ran out of context mid-slice. When the work
|
||||
below is done, fold anything durable into `PROJECT.md` (the only planning document) and **delete
|
||||
this file**. It is deliberately not in the Dockerfile COPY — docs are not served.
|
||||
|
||||
**Read `PROJECT.md` first, as always** — especially "How to work on this", the invariants, and the
|
||||
drill/match session logs (passes 13-23), which carry the reasoning behind every number named here.
|
||||
|
||||
---
|
||||
|
||||
## Where things stand
|
||||
|
||||
Zombie tennis is a **back-room prototype that plays well on a real phone** ("big improvement on real
|
||||
mobile"). Entry: splash → five taps in the top-left corner (`#hot`) → **MATCH TEST**, or `#match`.
|
||||
Sibling prototypes there: WEAPON TEST, BOSS TEST, DRILL TEST (the abandoned cognitive mode — see
|
||||
below). 49 tests green, seeds clean. Last commit `9b9f18d`, `index.html` at `?v=176`.
|
||||
|
||||
**What it is:** real tennis against the racket zombie. One game per opponent, tap-and-timing
|
||||
controls, the deuce/ad serve positions, and **the score is the body** — each point you win takes a
|
||||
quarter of its pool and walks it through its wound stages; the game-winning point fells it and a
|
||||
fresh opponent shambles on. Its points draw your blood (`matchPlayerHp` 8, `matchHeal` 2 per game
|
||||
won). It runs on a PERMANENT interlude — `G.interlude` is what makes serving tennis-like, hides the
|
||||
rack and wakes `racketAI` — and the only change is in `resolveInterlude`, which feeds `matchPoint`
|
||||
instead of ending anything. All of it is ~120 lines in `beats.js`.
|
||||
|
||||
**The ladder** escalates in `racketAI`, not hp: speed (`matchSpeedBase`/`Per`), whiff
|
||||
(`matchFlub`/`FlubKeep`), width (`matchWideBase`/`WidePer`), pace (`matchOppPace`/`PacePer`). Player
|
||||
side: `matchAssist`, `matchReach`.
|
||||
|
||||
---
|
||||
|
||||
## THE DECISION JUST MADE — scoring model A
|
||||
|
||||
Oliver picked **A: blood-only**, of three options offered:
|
||||
|
||||
- **A (chosen)** — the run is one long bleed across opponents; losing a game costs nothing but the
|
||||
blood already spent, and heals are how you last. **The measure becomes OPPONENTS FELLED.**
|
||||
- B — tennis-true: a lost game ends the run. Rejected for now (worth one playtest someday; it might
|
||||
be the mode's real personality).
|
||||
- C — hybrid, lost game drops a rung. Rejected.
|
||||
|
||||
**Important context for whoever implements this:** Oliver believed losing a match killed him
|
||||
("i really die losing one match"). It does not — he was bleeding out during deuce wars and hitting
|
||||
zero at their game point. **That misreading is itself the bug to fix**: the mode is not
|
||||
communicating its own rules. Model A only works if the player can see they are bleeding, not losing.
|
||||
|
||||
---
|
||||
|
||||
## The build slice, in order
|
||||
|
||||
1. **Run summary + the measure** *(small, do first — it is what makes A legible)*
|
||||
- Track and display **opponents felled**, not `G.runGames`. Today `matchGameWon` bumps
|
||||
`G.runGames` (beats.js:308) because every mode uses it as the ladder metric; the match needs
|
||||
its own count (`G.match.opp - 1` already is it) surfaced on the death screen and in a
|
||||
match-specific localStorage best (`drillBest` is the pattern to copy).
|
||||
- Death screen should speak tennis: "FELLED 3 OPPONENTS · 14 GAMES". `killPlayer` already owns
|
||||
the single death path and the run summary.
|
||||
- While here: make the blood legible so A reads correctly. The vignette exists (four linear edge
|
||||
fades, sqrt curve) — check on a phone whether hits 1-2 of 8 land hard enough.
|
||||
|
||||
2. **Named opponent ladder** *(medium, mostly data)*
|
||||
- `BOSS_ART` already holds **ten named designs with wound stages and throw frames** —
|
||||
GROUNDSKEEPER, LINE JUDGE, CHAIR UMPIRE, HEAD COACH, PHYSIO, SPONSOR MASCOT, THE CHAMPION at
|
||||
ten. Rung N gets a name plate and a personality row.
|
||||
- Personalities are the point, not the names: serve-and-volley, drop-shot merchant, **body-shot
|
||||
thrower** — note `shots[]` and the PARRY are fully built and completely unused in the match.
|
||||
That is the cheapest depth available anywhere in this mode.
|
||||
|
||||
3. **Wound-driven play** *(small, big payoff)* — the injuries should MEAN something: a one-armed
|
||||
opponent loses reach, a headless one flubs more. Score-is-the-body becomes score-changes-the-game.
|
||||
Read the fraction the way `drawZombie` and `bleed()` do (never literal hp).
|
||||
|
||||
4. **The fall animation** *(art pass — Oliver's idea, and a good one)* — point lost → the corpse
|
||||
COLLAPSES → drags itself up minus the limb. It explains the wound stage popping between points and
|
||||
gives every point a payoff beat. `matchReady()` is the natural slot. **Constraint: draw frames,
|
||||
do not fake them** (settled rule) — this needs a small fall/rise sheet per design through
|
||||
`assets/_regen.py`, not a JS rotate/sink.
|
||||
|
||||
5. **Menu entry** *(small)* — the three-mode select Oliver planned: CLASSIC TENNIS / ZOMBIE TENNIS /
|
||||
SPLATTER GORE, replacing the back-room door for this mode.
|
||||
|
||||
6. **Teaching** *(small)* — a ghost-hand lesson for the PLACEMENT FLICK. It is the counter-attack
|
||||
(flick during the swing aims the return; wrong-footing is how points are won) and it is taught
|
||||
nowhere on touch. The placement ring (`G.placeMark`) already shows the result; nothing teaches
|
||||
the cause.
|
||||
|
||||
7. **Opponent serve games** *(bigger, later)* — the zombie never serves today. Alternating service
|
||||
is real tennis and doubles the skill surface (return timing).
|
||||
|
||||
Expansion ideas that survived discussion, in `PROJECT.md`'s LATER: the **tournament frame**
|
||||
(QUALIFYING → ROUND OF 16 → THE FINAL vs THE CHAMPION — gives the run a destination), the Hawk-Eye
|
||||
**challenge** as an earned continue, sudden-death deuce past a rung, doubles, and **item 7:
|
||||
multiplayer** (ghost match first).
|
||||
|
||||
---
|
||||
|
||||
## THE TOOL THAT MATTERS: the flight recorder
|
||||
|
||||
Four rounds of "still the same behaviour" were ended by watching Oliver play, not by measuring with
|
||||
an autopilot. **Autopilot measurement lied for three passes straight** because the autopilot set
|
||||
`autoAssist` by hand and therefore had legs the real player did not.
|
||||
|
||||
Recipe — inject into the Browser pane, let him play, then read it:
|
||||
|
||||
```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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
Reference in new issue
Block a user