diff --git a/HANDOFF.md b/HANDOFF.md
index ebcfc73..1c7a367 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -418,6 +418,24 @@ free to be the KILL range instead of a number tuned to one camera.
**Weapon sounds** are a `sfx` field on the WEAPONS row: `thump` (beacon, `boom`), `swoosh` (rocket,
`whoosh`), otherwise the gun's every-other-round tick.
+## THE BLINKING BODIES, and why a missing frame is worse than a missing sheet (2026-08-03)
+
+Oliver: "on iphone the zombies were blinking in and out of existence". **`drawZombie` validated its
+frame count against the LAST frame only** — `if (!SPR['z_' + v + N]) N = 2` — so a set with, say,
+`z_h2` missing but `z_h4` present kept `N = 4`, the walk cycle stepped onto a name that had never
+loaded, and `drawStatic` silently drew NOTHING. The body vanished for that frame of the cycle and
+came back on the next one, forever. A single cached 404 (this project's oldest trap) is enough.
+
+`if (!SPR[name] && SPR['z_' + v + '1']) name = 'z_' + v + '1';` — hold frame one. **A body that stops
+animating is a bug you can see and diagnose; one that flickers in and out reads as the game
+breaking.** Every silent `SPR[x] ? x : fallback` in this file deserves the same suspicion.
+
+**And no `ctx.filter`, anywhere.** The cut line was briefly the sprite redrawn clipped to a band and
+flattened to one colour by a canvas filter, which put it exactly on the silhouette. On an iPhone the
+line did not draw AT ALL. Whether that was the filter being unsupported or leaking past its
+`restore()`, a rendering feature that fails silently on the target device is not worth a pixel of
+polish — the line is a plain bar now and crosses the odd gap between the legs, which is the trade.
+
## The laser plane, and cutting bodies with it (2026-08-03)
Settled with Oliver in discussion, then built. **The beam is a real plane at `TUNE.beamY` (1.15 m)**
@@ -425,6 +443,12 @@ that holds that height all the way out instead of sloping to the ground — that
makes the rest possible. Sloping, it lay flat on the grass across the whole far half, so there was
nothing for it to cut exactly where the crowd stands.
+- **The fill is TWO gradients**, matching the reference Oliver sent: a horizontal one across the
+ width (near-white down the centre line, falling to the weapon's blue at the edges) and a depth one
+ along the length. Canvas has no 2D gradient, so the wedge is filled in eight slices along its
+ length, each with its own across-gradient at that slice's depth alpha — enough slices that the
+ seams vanish. The two long edges are STROKED: the reference reads as a plane because its edges are
+ drawn, not merely where the fill stops.
- **One flat translucent triangle**, apex at the player's CHEST (`beamOriginY` 1.25 — emitted from
the racket head at 2.2 m the apex hung in the air and read as detached), opening out down the
court, alpha falling with distance. It was four nested wedges composited `lighter`, and that
diff --git a/config.js b/config.js
index 25605e3..2bae747 100644
--- a/config.js
+++ b/config.js
@@ -234,11 +234,11 @@ export const TUNE = {
// MEDIUM wave regardless of how far into a run you are, and every barrel weapon on the rack,
// kept topped up so nothing runs dry mid-test. It overrides the difficulty director rather than
// editing it — G.runGames keeps climbing underneath and the run is still a real run.
- testZMax: 8, // bodies on court: enough to test a crowd weapon, few enough to read
+ testZMax: 16, // bodies on court. Was 8 and the sandbox kept running dry between kills — a
+ // crowd weapon needs a crowd to test it against
testZSpeed: 1.15, // m/s shamble — medium, and FIXED (no per-game ramp)
- testSpawnEvery: 1.6,// s between spawns
+ testSpawnEvery: 0.9,// s between spawns: they keep coming
testLevel: 20, // the roster level pickVariant pretends to be at, so every body type can turn up
- testBarrelEvery: 14,// s between barrels, so the pickup itself is testable too
// ---- THE LASER's wedge: aim and release like a serve, and a plane of light widens down the lane
beamLen: 34, // m it reaches. Was 26, which stopped at the far baseline and read as the beam
// being CUT OFF in mid-court; the player stands at z~24, so this carries it past
diff --git a/gameplay.js b/gameplay.js
index 72f13bb..9438b3d 100644
--- a/gameplay.js
+++ b/gameplay.js
@@ -559,7 +559,7 @@ export function toggleTestMode() {
stockTestRack();
G.flash = { text: 'TEST MODE', t: 1.6 };
showModal({ title: 'TEST MODE', color: UI.pink,
- body: ['A STEADY MEDIUM WAVE', 'EVERY BARREL WEAPON, KEPT FULL',
+ body: ['A STEADY CROWD, MEDIUM SPEED', 'EVERY BARREL WEAPON, KEPT FULL',
'', 'TRIPLE-TAP THE SCOREBOARD AGAIN TO STOP'] });
return true;
}
@@ -573,16 +573,10 @@ function stockTestRack() {
else if (G.rack.length < RACK_MAX) G.rack.push({ id, n: WEAPONS[id].tin });
}
}
-let testBarrelT = 0;
export function tickTestMode(dt) {
if (!G.testMode) return;
- stockTestRack();
- if (G.mode !== 'splatter' || G.bossFight || G.interlude) return;
- if ((testBarrelT -= dt) <= 0) {
- testBarrelT = TUNE.testBarrelEvery;
- if (!zombies.some(z => z.variant === 'o')) spawnBarrel();
- }
-}
+ stockTestRack(); // no barrels: the rack is already full of everything they would hand you, and
+} // one rolling across the sandbox was just something else to look at
// ---- the interlude: order is restored, one sudden-death point vs the racket zombie.
// Tennis rules apply for exactly one point — the absurdity peak: the umpire never stopped watching.
diff --git a/index.html b/index.html
index dc95043..dafc902 100644
--- a/index.html
+++ b/index.html
@@ -179,6 +179,6 @@
-
+