Let the player walk between points, and fix seven retest gripes

All eight issues from Oliver's 35-game retest.

The freeze (1 and 6 — one root cause): the movement block, pose update
included, lived inside `if (G.phase === 'rally')`, so every 'over' beat and
every pretoss stranded the player mid-animation while the zombies walked on.
It now also runs whenever splatter is in any phase but 'toss' — 'toss' is the
aim, and the thumb is busy. Tennis keeps its old gating.

The rest:
- the mop boy is waved off the sudden-death duel and barred for its duration
- triple-serve clones inherit the serve's topspin gravity, so the wings stop
  floating while the centre ball dives
- "Time." belongs to the changeover's own countdown, not to whatever beat
  happened to expire while the breather ran
- the tutorial illustration is a finger pressing and pulling back now, not a
  curved arrow that read as a scroll
- aiming holds the pretoss stance; the smash plays when the shot fires
- GAME N sits on the scoreboard permanently

HANDOFF records all eight as closed with what the fix was, plus two ways a
soak rig lies to you and why the CPU-return benchmark can't be reproduced
from the number alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-07-30 09:33:37 +02:00
1 parent 8dca9b6d45
commit e00886f57e
7 files changed
+89 -52

No files matched your search

+63 -44
View File
@@ -5,7 +5,9 @@ everything they specified is shipped, and what still matters from them is record
long history, `~/.claude/projects/.../memory/splatter-tennis-history.md` is auto-loaded; for the
detail of any individual change, read the git log.
Last updated 2026-07-27 after Oliver's 35-game retest. **Open issues are at the bottom — start there.**
Last updated 2026-07-30. All eight issues from Oliver's 35-game retest are fixed and verified in the
browser; they are recorded at the bottom as **CLOSED** with what the fix was, because each one is a
trap worth not re-digging. **Awaiting Oliver's next phone playtest.**
## What this is
SNES-style pseudo-3D tennis (Super Tennis reference) that transitions into a zombie splatter game
@@ -116,9 +118,22 @@ Clear the intro first: `document.getElementById('intro')?.remove()`.
**Benchmarks that must keep holding** (re-run after any physics/aim change):
- CPU returns ~11/12 serves (91.7%). Recent 150-sample runs: 90.7%, 89.3%, 90.7%, 88.7% (±2.5%).
Watch this — the last few sit under the bar rather than scattered around it.
**The harness for this number was never written down and I could not reconstruct it.** A rig that
counts "did the CPU get a racket on the served ball" scores 100/100, because after a flub the CPU
gets another swing at the SAME ball (`aiSwingCool = 0.5`) and connects — 91.7% ≈ 1 − aiFlub, so the
recorded number must count only the FIRST swing opportunity, or only returns that land in.
Until someone writes the rig down, prove "no tennis regression" **differentially** instead: run
your rig, `git stash` the change, reload the modules, run the identical rig, compare. Equal
numbers on the same rig is the real claim; a bare percentage against this line is not.
- 30 human-tapped points (tap when the incoming ball is 2.2–3.2 m away) = **50 hits / 0 whiffs**.
This one DOES reproduce exactly — drive it off `G.missLog` (HIT vs MISS counts).
- A 26-game state-machine soak with bombs/bursts/fire must finish with no exceptions, no stall,
`mgT` back to 0, and gore/piles arrays bounded.
`mgT` back to 0, and gore/piles arrays bounded (gore caps at 40, piles at 30).
**Two ways a soak lies to you**, both cost me a run: force-spawning zombies during `G.clearing`
makes the hand-clear un-completable and reads as a 30-minute stall; and reviving the player after
each `eaten` inflates gore past its cap, because the death push (`zombies.js`, the `eatenR` hit) is
the one uncapped `gore.push` in the file — fine at one death per run, not at 260. Respect
`clearing`, and either end the soak on death or set `TUNE.eatenR = 0` to make him untouchable.
- **Never trust a single sample.** aiFlub is 9%; a 20/24 run is normal noise. Use 150.
## Asset pipeline
@@ -170,52 +185,56 @@ Known generator quirks, all still true:
---
# OPEN ISSUES — from Oliver's 35-game retest (2026-07-27)
Nothing below is fixed. Diagnoses are from reading the code this session; verify before trusting.
# CLOSED — Oliver's 35-game retest (2026-07-27), all fixed 2026-07-30
Kept because each one is a trap worth not re-digging. Every fix was verified in the browser via
`window.T`; the numbers quoted are from those runs.
### 1. Player frozen (and stuck mid-animation) during the between-game pauses — HIGH
During the ~5s changeover the player cannot move but the zombies keep walking. The whole movement
block, including the `P.pose = moving ? 'run' : 'idle'` update, lives inside
`if (G.phase === 'rally')` in `update()`. Every 'over' beat (changeover, game beat, set card)
therefore freezes the player in whatever pose he held while `updateZombies` runs on.
**This is almost certainly also issue 6** (freeze after the horde) — `endHorde` hands back to a
phase where the movement block doesn't run.
Oliver's stated options: *drop the 5s entirely, or stop the zombies for its duration.* Stopping the
zombies is the more characterful of the two (the boys mop, everyone waits) but letting the player
walk during 'over' in splatter is the smaller diff and fixes the stuck pose too. Consider both.
### 1 + 6. Player frozen (and stuck mid-animation) during the between-game pauses — was HIGH
Same root cause, one fix. The whole movement block — including `P.pose = moving ? 'run' : 'idle'` —
lived inside `if (G.phase === 'rally')`, so every 'over' beat (changeover, game beat, set card) and
every `pretoss` froze the player while `updateZombies` ran on. That `pretoss` case is issue 6:
`endHorde` calls `setupPoint()`, which hands back to `pretoss`.
The block now also runs whenever `G.mode === 'splatter' && G.phase !== 'toss'`. `'toss'` stays frozen
on purpose — that is the aim, and the thumb is busy drawing the bow. **Tennis is deliberately
untouched** (verified: a keyboard-held ArrowLeft moves the player 0.00 m during a tennis 'over',
−2.70 m during a splatter one).
Oliver's other option — stopping the zombies for the breather — was NOT taken. `startChangeover`'s
"it's calm, not safe" is still the design; being able to walk was the actual complaint.
### 2. The mop boy walks on during the 1:1 duel — LOW
`sendBoy` has no `G.interlude` guard and `startDuel` doesn't clear `G.boy`. The duel is meant to be
clean tennis; nobody should be mopping through it.
### 2. The mop boy walks on during the 1:1 duel — was LOW
`sendBoy` now returns early on `G.interlude`, and `startDuel` sets `G.boy = null`. Note the ordering
that makes this safe: `resolveInterlude('P')` clears `G.interlude` **before** calling `deliverTin()`,
so the boy still comes out with the tin.
### 3. Triple serve: the two wing balls lob off at the wrong height — MEDIUM
Confirmed cause. The real serve carries topspin gravity `ball.g = TUNE.gravity * (1 + quality *
TUNE.serveSpin)` — up to **2.3×** `TUNE.gravity` at full power. The clones in `extras` are
integrated at flat `TUNE.gravity` (`zombies.js`, the `for (let i = extras.length - 1 …)` loop:
`e.vy -= TUNE.gravity * dt`). So the centre ball dives and the wings float long.
Fix: copy `g: ball.g` onto each extra where they're pushed (`gameplay.js` in `hitServe`, and the
re-fire clone lower down) and integrate with `e.g || TUNE.gravity`.
### 3. Triple serve: the two wing balls lob off at the wrong height — was MEDIUM
The diagnosis in the old text was right. `g: ball.g` is now copied onto each clone (both push sites:
`hitServe`'s triple fan and `dragServe`'s re-fire clone) and `zombies.js` integrates
`e.vy -= (e.g || TUNE.gravity) * dt`. Verified at full pull: clones carry g = 21.16 (2.3 × the 9.2
base) instead of 9.2, and centre and wing apex heights now match exactly (2.90 m both).
### 4. The umpire calls "Time" on pauses that aren't changeovers — LOW
`voice('time')` fires in the `over` branch of `update()` whenever `G.changeover` clears. Needs to
distinguish a real changeover from other forced waits, or the line needs to move.
### 4. The umpire calls "Time" on pauses that aren't changeovers — was LOW
`G.changeover` is now the **seconds left**, not a flag (still truthy, so every `!G.changeover` guard
reads the same), and it counts itself down near the top of `update()`, calling `voice('time')` when it
hits zero. It no longer rides the generic `G.overT` expiry — anything that overwrote `overT` mid-
breather (a melee kill taking a game, say) used to make the umpire call time on a pause that was
never a changeover. Verified with a `fetch` spy on `assets/audio/time.m4a`: an unrelated `overT`
expiry at t=0.4 s says nothing and leaves 4.6 s on the breather; the line fires exactly once, when
the breather's own clock runs out.
### 5. The transition tutorial illustration confuses people — MEDIUM
`assets/x_howto.png`. The curved arrow plus thumbprint reads as a rounding/scroll gesture rather
than a pull-back. Oliver: *"maybe display a pulling and aiming finger?"* Worth regenerating as a
literal finger pressing down and dragging back with a motion trail, and re-checking it at the
modal's drawn size (~89×120 logical px in portrait) rather than at full resolution.
Note `drawPullHint()` now demos the gesture live in-game, so the illustration can lean on that.
### 5. The transition tutorial illustration confuses people — was MEDIUM
`assets/x_howto.png` regenerated (Gemini 3 Pro, edit mode with the old sheet as `--input`, then
`assets/_key.py … 240`). The curved arrow and thumbprint are gone; it is now a press ring at the top,
a straight vertical dashed track, two chevrons pointing DOWN, and a fingertip at the bottom — with the
yellow shot arrow flying the opposite way to the zombie, which is the inversion lesson. Aspect is
unchanged (240×321 vs 240×322), so the modal still draws it at 90×120 in portrait. Checked at that
drawn size, not at full resolution.
### 6. Sprite freeze after the horde — HIGH
See issue 1; very likely the same root cause.
### 7. Aiming plays the toss animation; it should stay in the pretoss stance — was MEDIUM
`update()`'s forced `sv.pose = 'serve'` for `phase === 'toss'` is now gated on `!G.aim`: while the
slingshot is drawn the server holds the pretoss stance, and only `hitServe` puts him into the
toss/smash. Verified: plain tap → pose `serve`; drawn bow → pose `idle`.
### 7. Aiming plays the toss animation; it should stay in the pretoss stance — MEDIUM
`input.js` touchmove calls `serveToss()` on the first drag, which sets `G.phase = 'toss'`, and
`update()` forces `sv.pose = 'serve'` for the whole of `phase === 'toss'`. So the player is in the
toss/serve pose the entire time he is aiming. Oliver wants the **pretoss** stance while aiming, with
the toss/smash playing when the shot actually fires.
### 8. Show the current game number persistently — LOW
Today it only appears as a 1.1s `GAME N` flash when the next game arms. Wants it visible somewhere
permanent — the scoreboard is the obvious home (it already has a sets column in splatter).
### 8. Show the current game number persistently — was LOW
`GAME N` now sits permanently under the scoreboard, right-aligned to the board's width, on the same
row as the NEXT pips and in the same dim tone (`render.js`, just before the pips block). The 1.1 s
flash when a game arms is still there.