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 long history, `~/.claude/projects/.../memory/splatter-tennis-history.md` is auto-loaded; for the
detail of any individual change, read the git log. 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 ## What this is
SNES-style pseudo-3D tennis (Super Tennis reference) that transitions into a zombie splatter game 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): **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%). - 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. 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**. - 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, - 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. - **Never trust a single sample.** aiFlub is 9%; a 20/24 run is normal noise. Use 150.
## Asset pipeline ## Asset pipeline
@@ -170,52 +185,56 @@ Known generator quirks, all still true:
--- ---
# OPEN ISSUES — from Oliver's 35-game retest (2026-07-27) # CLOSED — Oliver's 35-game retest (2026-07-27), all fixed 2026-07-30
Nothing below is fixed. Diagnoses are from reading the code this session; verify before trusting. 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 ### 1 + 6. Player frozen (and stuck mid-animation) during the between-game pauses — was HIGH
During the ~5s changeover the player cannot move but the zombies keep walking. The whole movement Same root cause, one fix. The whole movement block — including `P.pose = moving ? 'run' : 'idle'` —
block, including the `P.pose = moving ? 'run' : 'idle'` update, lives inside lived inside `if (G.phase === 'rally')`, so every 'over' beat (changeover, game beat, set card) and
`if (G.phase === 'rally')` in `update()`. Every 'over' beat (changeover, game beat, set card) every `pretoss` froze the player while `updateZombies` ran on. That `pretoss` case is issue 6:
therefore freezes the player in whatever pose he held while `updateZombies` runs on. `endHorde` calls `setupPoint()`, which hands back to `pretoss`.
**This is almost certainly also issue 6** (freeze after the horde) — `endHorde` hands back to a The block now also runs whenever `G.mode === 'splatter' && G.phase !== 'toss'`. `'toss'` stays frozen
phase where the movement block doesn't run. on purpose — that is the aim, and the thumb is busy drawing the bow. **Tennis is deliberately
Oliver's stated options: *drop the 5s entirely, or stop the zombies for its duration.* Stopping the untouched** (verified: a keyboard-held ArrowLeft moves the player 0.00 m during a tennis 'over',
zombies is the more characterful of the two (the boys mop, everyone waits) but letting the player −2.70 m during a splatter one).
walk during 'over' in splatter is the smaller diff and fixes the stuck pose too. Consider both. 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 ### 2. The mop boy walks on during the 1:1 duel — was LOW
`sendBoy` has no `G.interlude` guard and `startDuel` doesn't clear `G.boy`. The duel is meant to be `sendBoy` now returns early on `G.interlude`, and `startDuel` sets `G.boy = null`. Note the ordering
clean tennis; nobody should be mopping through it. 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 ### 3. Triple serve: the two wing balls lob off at the wrong height — was MEDIUM
Confirmed cause. The real serve carries topspin gravity `ball.g = TUNE.gravity * (1 + quality * The diagnosis in the old text was right. `g: ball.g` is now copied onto each clone (both push sites:
TUNE.serveSpin)` — up to **2.3×** `TUNE.gravity` at full power. The clones in `extras` are `hitServe`'s triple fan and `dragServe`'s re-fire clone) and `zombies.js` integrates
integrated at flat `TUNE.gravity` (`zombies.js`, the `for (let i = extras.length - 1 …)` loop: `e.vy -= (e.g || TUNE.gravity) * dt`. Verified at full pull: clones carry g = 21.16 (2.3 × the 9.2
`e.vy -= TUNE.gravity * dt`). So the centre ball dives and the wings float long. base) instead of 9.2, and centre and wing apex heights now match exactly (2.90 m both).
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`.
### 4. The umpire calls "Time" on pauses that aren't changeovers — LOW ### 4. The umpire calls "Time" on pauses that aren't changeovers — was LOW
`voice('time')` fires in the `over` branch of `update()` whenever `G.changeover` clears. Needs to `G.changeover` is now the **seconds left**, not a flag (still truthy, so every `!G.changeover` guard
distinguish a real changeover from other forced waits, or the line needs to move. 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 ### 5. The transition tutorial illustration confuses people — was MEDIUM
`assets/x_howto.png`. The curved arrow plus thumbprint reads as a rounding/scroll gesture rather `assets/x_howto.png` regenerated (Gemini 3 Pro, edit mode with the old sheet as `--input`, then
than a pull-back. Oliver: *"maybe display a pulling and aiming finger?"* Worth regenerating as a `assets/_key.py … 240`). The curved arrow and thumbprint are gone; it is now a press ring at the top,
literal finger pressing down and dragging back with a motion trail, and re-checking it at the a straight vertical dashed track, two chevrons pointing DOWN, and a fingertip at the bottom — with the
modal's drawn size (~89×120 logical px in portrait) rather than at full resolution. yellow shot arrow flying the opposite way to the zombie, which is the inversion lesson. Aspect is
Note `drawPullHint()` now demos the gesture live in-game, so the illustration can lean on that. 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 ### 7. Aiming plays the toss animation; it should stay in the pretoss stance — was MEDIUM
See issue 1; very likely the same root cause. `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 ### 8. Show the current game number persistently — was LOW
`input.js` touchmove calls `serveToss()` on the first drag, which sets `G.phase = 'toss'`, and `GAME N` now sits permanently under the scoreboard, right-aligned to the board's width, on the same
`update()` forces `sv.pose = 'serve'` for the whole of `phase === 'toss'`. So the player is in the row as the NEXT pips and in the same dim tone (`render.js`, just before the pips block). The 1.1 s
toss/serve pose the entire time he is aiming. Oliver wants the **pretoss** stance while aiming, with flash when a game arms is still there.
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).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 51 KiB

+19 -6
View File
@@ -196,7 +196,10 @@ function hitServe(sv, quality, aim = null) {
G.triples--; G.triples--;
for (const deg of [-6, 6]) { for (const deg of [-6, 6]) {
const r = deg * Math.PI / 180, c = Math.cos(r), sn = Math.sin(r); const r = deg * Math.PI / 180, c = Math.cos(r), sn = Math.sin(r);
extras.push({ x: ball.x, y: ball.y, z: ball.z, vy: ball.vy, pow: quality, t: 2.5, // g: ball.g — a serve carries up to 2.3x gravity in topspin; clones integrated at flat
// TUNE.gravity floated long while the centre ball dived, which is the "wings lob off at
// the wrong height" report. They are the SAME shot, fanned.
extras.push({ x: ball.x, y: ball.y, z: ball.z, vy: ball.vy, pow: quality, t: 2.5, g: ball.g,
vx: ball.vx * c - ball.vz * sn, vz: ball.vx * sn + ball.vz * c }); vx: ball.vx * c - ball.vz * sn, vz: ball.vx * sn + ball.vz * c });
} }
} }
@@ -335,7 +338,7 @@ export function proceedAfterClear() {
// changeover (after odd games, as the rules demand): a forced breather — no spawns, // changeover (after odd games, as the rules demand): a forced breather — no spawns,
// no serving, the boys mop. Zombies already on court keep walking; it's calm, not safe. // no serving, the boys mop. Zombies already on court keep walking; it's calm, not safe.
function startChangeover() { function startChangeover() {
G.changeover = true; G.changeover = TUNE.changeoverT; // seconds left, not a flag: "Time." is called when THIS runs out
G.phase = 'over'; G.overT = TUNE.changeoverT; G.phase = 'over'; G.overT = TUNE.changeoverT;
// out with the mop, across the near court where the gore is — NOT to the ball: a dead ball // out with the mop, across the near court where the gore is — NOT to the ball: a dead ball
// parked off the field (sendBoy's z guard) meant no boy came out at all, and the breather is // parked off the field (sendBoy's z guard) meant no boy came out at all, and the breather is
@@ -387,6 +390,7 @@ export function startDuel() {
G.weapon = 0; // tennis rules for the duel: plain balls, no tins G.weapon = 0; // tennis rules for the duel: plain balls, no tins
G.mgT = 0; pellets.length = 0; // ...and a burst in progress is over. The umpire has restored order. G.mgT = 0; pellets.length = 0; // ...and a burst in progress is over. The umpire has restored order.
G.interlude = { introT: 2.4 }; // the intro beat: serving blocked while the umpire announces G.interlude = { introT: 2.4 }; // the intro beat: serving blocked while the umpire announces
G.boy = null; // ...and the mop boy is waved off the court (sendBoy is barred for the duration)
ball.active = false; setupPoint(); ball.active = false; setupPoint();
zombieDuck(true); // fade the zombie music out for the 1:1 duel zombieDuck(true); // fade the zombie music out for the 1:1 duel
voice('play_resume', 0.2); voice('sudden_death', 2.4); voice('play_resume', 0.2); voice('sudden_death', 2.4);
@@ -683,7 +687,7 @@ export function dragServe(aim) {
// rally clock. This is what lets the drag mean ONE thing at all times: a second gesture that // rally clock. This is what lets the drag mean ONE thing at all times: a second gesture that
// walked the player around between shots was the confusing part, not the fire rate. // walked the player around between shots was the confusing part, not the fire rate.
if (ball.active && G.phase !== 'toss') // the old ball flies on as a ballistic clone if (ball.active && G.phase !== 'toss') // the old ball flies on as a ballistic clone
extras.push({ x: ball.x, y: ball.y, z: ball.z, vx: ball.vx, vy: ball.vy, vz: ball.vz, pow: ball.pow || 0.5, t: 2.5 }); extras.push({ x: ball.x, y: ball.y, z: ball.z, vx: ball.vx, vy: ball.vy, vz: ball.vz, pow: ball.pow || 0.5, t: 2.5, g: ball.g });
ball.active = true; ball.collected = false; ball.bounces = 0; ball.firstLand = null; ball.active = true; ball.collected = false; ball.bounces = 0; ball.firstLand = null;
ball.x = P.x + 0.35; ball.z = P.z; ball.y = 2.2; ball.g = TUNE.gravity; ball.x = P.x + 0.35; ball.z = P.z; ball.y = 2.2; ball.g = TUNE.gravity;
hitServe(P, pullQuality(aim.mag), aim); // a full pull is quality 1.0 — PERFECT, hitstop and all hitServe(P, pullQuality(aim.mag), aim); // a full pull is quality 1.0 — PERFECT, hitstop and all
@@ -797,14 +801,24 @@ export function update(dt) {
if (G.modal) { G.idleT += dt; return; } if (G.modal) { G.idleT += dt; return; }
crowd.cheer = Math.max(0, crowd.cheer - dt); crowd.cheer = Math.max(0, crowd.cheer - dt);
G.idleT += dt; G.idleT += dt;
// "Time." belongs to the changeover alone. It used to ride the generic 'over' expiry, so any
// other beat that overwrote overT while the breather ran (a melee kill taking a game, say)
// had the umpire calling time on a pause that was never a changeover.
if (G.changeover > 0 && (G.changeover -= dt) <= 0) { G.changeover = false; voice('time', 0, 0.95); }
const w = BOUNCE_IDLE.tempo; const w = BOUNCE_IDLE.tempo;
// idle in the serve stance and not touching anything: after a beat, show the pull gesture // idle in the serve stance and not touching anything: after a beat, show the pull gesture
if (G.phase === 'pretoss' && !G.aim) G.idleHintT += dt; else G.idleHintT = 0; if (G.phase === 'pretoss' && !G.aim) G.idleHintT += dt; else G.idleHintT = 0;
if (G.phase === 'pretoss' && Math.sin(G.idleT * w) * Math.sin((G.idleT - dt) * w) < 0) pock(0.12, 420); // ball bounce if (G.phase === 'pretoss' && Math.sin(G.idleT * w) * Math.sin((G.idleT - dt) * w) < 0) pock(0.12, 420); // ball bounce
if (G.phase === 'toss') { const sv = score.server === 'P' ? P : O; sv.pose = 'serve'; sv.poseT = 0; } // the toss/smash pose belongs to the SHOT, not to the aim: while the slingshot is drawn (G.aim)
// the server stays in the pretoss stance and only snaps into 'serve' when hitServe fires.
if (G.phase === 'toss' && !G.aim) { const sv = score.server === 'P' ? P : O; sv.pose = 'serve'; sv.poseT = 0; }
// --- player movement (near half only, generous margins) // --- player movement (near half only, generous margins)
if (G.phase === 'rally') { // In splatter it also runs between points ('over' — game beats, changeovers, set cards — and
// 'pretoss'): the zombies never stop walking, so gating movement on 'rally' froze the player
// mid-stride with the dead closing in, and left him stuck in whatever pose he held. 'toss' is
// the one exception — that's the aim, and the thumb is busy drawing the bow.
if (G.phase === 'rally' || (G.mode === 'splatter' && G.phase !== 'toss')) {
if (G.mode === 'tennis' && ball.active && ball.lastHit === 'O') { // closest approach, for never-swung passes if (G.mode === 'tennis' && ball.active && ball.lastHit === 'O') { // closest approach, for never-swung passes
const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8); const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8);
if (!G.rallyNear || d < G.rallyNear.d) G.rallyNear = { d, y: ball.y, bz: ball.z, px: P.x, pz: P.z }; if (!G.rallyNear || d < G.rallyNear.d) G.rallyNear = { d, y: ball.y, bz: ball.z, px: P.x, pz: P.z };
@@ -1003,7 +1017,6 @@ export function update(dt) {
onDismiss: startHorde }); onDismiss: startHorde });
return; return;
} }
if (G.changeover) { G.changeover = false; voice('time', 0, 0.95); } // "Time." — play resumes
// auto-fire modes must NEVER re-enter the serve loop: an 'over' beat expiring into // auto-fire modes must NEVER re-enter the serve loop: an 'over' beat expiring into
// setupPoint() -> pretoss left the player locked in the serve posture while the gun // setupPoint() -> pretoss left the player locked in the serve posture while the gun
// fired (no movement outside 'rally', no serving during auto-fire). Back to rally. // fired (no movement outside 'rally', no serving during auto-fire). Back to rally.
+1 -1
View File
@@ -60,6 +60,6 @@
<p class="start">TAP TO START</p> <p class="start">TAP TO START</p>
</div> </div>
</div> </div>
<script type="module" src="main.js?v=35"></script> <script type="module" src="main.js?v=36"></script>
</body> </body>
</html> </html>
+4
View File
@@ -681,6 +681,10 @@ export function render() {
ctx.font = F(8); ctx.fillStyle = bd.cream; ctx.fillText(String(score.games[i]), 90, cy); // games ctx.font = F(8); ctx.fillStyle = bd.cream; ctx.fillText(String(score.games[i]), 90, cy); // games
ctx.font = F(8); ctx.fillStyle = svr ? bd.gold : bd.cream; ctx.fillText(ptTxt(i), 126, cy); // points, in the art's slot ctx.font = F(8); ctx.fillStyle = svr ? bd.gold : bd.cream; ctx.fillText(ptTxt(i), 126, cy); // points, in the art's slot
} }
if (splat) { // which game you're in, permanently — the 1.1s GAME N flash was the only place it showed
ctx.textAlign = 'right'; ctx.font = F(6, 0); ctx.fillStyle = bd.dim;
ctx.fillText('GAME ' + (G.runGames + 1), bW, bH + 7);
}
if (splat && TUNE.killPips && TUNE.killsPerPoint > 1) { // kills-to-next-point pips, just under the board — no box if (splat && TUNE.killPips && TUNE.killsPerPoint > 1) { // kills-to-next-point pips, just under the board — no box
ctx.textAlign = 'left'; ctx.font = F(6, 0); ctx.fillStyle = bd.dim; ctx.fillText('NEXT', 2, bH + 7); ctx.textAlign = 'left'; ctx.font = F(6, 0); ctx.fillStyle = bd.dim; ctx.fillText('NEXT', 2, bH + 7);
for (let k = 0; k < TUNE.killsPerPoint; k++) { for (let k = 0; k < TUNE.killsPerPoint; k++) {
+1
View File
@@ -103,6 +103,7 @@ export function setupPoint() {
// walk, because mopping is not sprinting. Mopping is the DEFAULT once the gore starts; the plain // walk, because mopping is not sprinting. Mopping is the DEFAULT once the gore starts; the plain
// running fetch-boy survives for tennis mode, where there is nothing on the grass but a ball. // running fetch-boy survives for tennis mode, where there is nothing on the grass but a ball.
export function sendBoy(tx = ball.x, tz = ball.z, mop = G.mode === 'splatter') { export function sendBoy(tx = ball.x, tz = ball.z, mop = G.mode === 'splatter') {
if (G.interlude) return; // the sudden-death duel is clean tennis: nobody mops through it
if (tz < -3 || tz > 27) return; // target left the field lengthwise, let it go if (tz < -3 || tz > 27) return; // target left the field lengthwise, let it go
// ONE boy, one slot. Every kill used to call this and teleport him back to the wing mid-stride, // ONE boy, one slot. Every kill used to call this and teleport him back to the wing mid-stride,
// which is why he flickered in and out: a kill streak restarted him several times a second. // which is why he flickered in and out: a kill streak restarted him several times a second.
+1 -1
View File
@@ -382,7 +382,7 @@ export function updateZombies(dt) {
for (let i = extras.length - 1; i >= 0; i--) { // triple-serve side balls: full ballistic clones for (let i = extras.length - 1; i >= 0; i--) { // triple-serve side balls: full ballistic clones
const e = extras[i]; const e = extras[i];
e.t -= dt; e.vy -= TUNE.gravity * dt; e.t -= dt; e.vy -= (e.g || TUNE.gravity) * dt; // e.g: a clone flies under the SAME topspin as the ball it came from
e.x += e.vx * dt; e.y += e.vy * dt; e.z += e.vz * dt; e.x += e.vx * dt; e.y += e.vy * dt; e.z += e.vz * dt;
if (e.y <= 0 && e.vy < 0) { e.y = 0; e.vy = -e.vy * TUNE.bounceY; e.vx *= TUNE.bounceXZ; e.vz *= TUNE.bounceXZ; } if (e.y <= 0 && e.vy < 0) { e.y = 0; e.vy = -e.vy * TUNE.bounceY; e.vx *= TUNE.bounceXZ; e.vz *= TUNE.bounceXZ; }
let dead = e.t <= 0 || Math.abs(e.x) > 7.5 || e.z < -2 || e.z > 25.5; let dead = e.t <= 0 || Math.abs(e.x) > 7.5 || e.z < -2 || e.z > 25.5;