From bdc3f23f922ba6bc63a048a41cb83034a96aab8f Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Sun, 16 Aug 2026 08:45:31 +0200 Subject: [PATCH 1/2] A lane is free when its body is gone, not when it is far away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drill spawner measured its best-of-9 lane spacing only against bodies still in the back half (drillNearZ 9 m). On a court where lanes are HELD for life and speed is stamped at spawn, a body 12 m down still owns its lane — handing it to a faster arrival books an overtake for ten seconds later, mid-court, as two sprites walking through each other. That is what the seed-sensitive test failure (SEED=1234) was reporting: the assertion was right, the stream was not. Spacing now measures against every live drill body; drillNearZ is deleted (no other reader). Still best-of-9, no retry loop, so the stream cannot stall. 60 seeds x 150 s of drill: before, 7 seeds overlapped with narrowest clearance 0.00 m; after, zero overlap frames, narrowest 0.46 m (the test asks > 0.3). Suite green on 20260811/1234/777/99/424242/8 and on 299 of seeds 1..300; the straggler (SEED=80, the slid-serve return rate) is unrelated and logged as open. Co-Authored-By: Claude Opus 5 --- PROJECT.md | 29 ++++++++++++++++++++++++++--- index.html | 2 +- src/config.js | 1 - src/drill.js | 12 ++++++++---- 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index 1d969ed..c0d6252 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -414,6 +414,29 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu `RETURNED`, `DOUBLE KILL`) is a footnote to something you already watched happen. Sharing the loud FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone. +## Session log: a lane is free when its body is gone, not when it is far away (2026-08-16) + +A seed-sensitive red suite (`SEED=1234 node test.js` failed on the drill's "spaced *after the walk +in*" assertion) turned out to be the assertion telling the truth: **two drill bodies genuinely walked +through each other.** The spawner measured its best-of-9 lane spacing only against bodies with +`z < drillNearZ` (9 m) — sound for a court where bodies wander, wrong for this one, where a lane is +HELD for life and speed is stamped at spawn. A body 12 m down still owns its lane; handing that lane +to a faster arrival does not create a spacing problem at spawn, it books an overtake for ten seconds +later, mid-court, as two sprites occupying the same metre. The fix is the filter: spacing is measured +against every live drill body, `drillNearZ` is deleted (it had no other reader). No retry loop, so +the stream still cannot stall. + +Measured, 60 seeds × 150 s of drill each: before, **7 seeds produced overlaps, narrowest clearance +0.00 m**; after, **zero overlap frames, narrowest 0.46 m** (the test asks for > 0.3). Peak on court +is still the cap of 7, so the fan is wider, not thinner. Suite green on 20260811 / 1234 / 777 / 99 / +424242 / 8, and on 299 of seeds 1..300 — the one straggler is unrelated and still open (see below). + +**OPEN, found by the same fuzz:** `SEED=80` fails *"opponent one returns a slid serve: 5/10"*. That +assertion wants 6 of 10 sliced serves returned and the AI's real rate sits right on the threshold, so +it is a coin-flip gate of exactly the kind this file warns about. Untouched here because the honest +fix is a decision about the opponent, not the test: either widen `n` so the mean settles, or accept +5/10 as the real return rate and say so. + ## Session log: THE NETPLAY LOBBY — a link to send, a code to type, a name to be (2026-08-15) Oliver: *"multiplayer should be easier to setup — a share with link next to the code; also entering @@ -1000,9 +1023,9 @@ are the ones being called now, which is a better decoy than the old fixed-batch - **`PLACEMENTS` is deleted along with the marks.** Nothing rushes anywhere and nothing stops, so there are no shapes to place — the geometry now comes from lane choice at spawn plus the depth the stream naturally strings bodies out into. That is a net DELETION of the fanciest code in the file. -- **Spacing is best-of-9 lane sampling** against everyone still in the back half, not a retry loop: - a hard minimum would silently stall the stream at exactly the moment it is meant to be getting - harder, which reads as the mode dying. +- **Spacing is best-of-9 lane sampling** against every live drill body (it measured only the back + half until 2026-08-16, see that session log), not a retry loop: a hard minimum would silently stall + the stream at exactly the moment it is meant to be getting harder, which reads as the mode dying. - **A DRILL BODY HOLDS ITS LANE**, and this was the fix the screenshot forced. `updateZombies` homes bodies onto the player's x once they cross the net — a gore-mode behaviour — so they arrived nicely spread and then bunched into an unreadable knot right in front of you. Spawn spacing cannot survive diff --git a/index.html b/index.html index 70df955..e8a6351 100644 --- a/index.html +++ b/index.html @@ -659,6 +659,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { - + diff --git a/src/config.js b/src/config.js index b3655be..b94cb14 100644 --- a/src/config.js +++ b/src/config.js @@ -461,7 +461,6 @@ export const TUNE = { drillSeed: 4, // bodies placed at the start so there is a queue to read from the first second drillSeedGap: 2.6,// m of depth between those seeded bodies drillSpawnZ: 0.6, // z they enter at (the far baseline is 0) - drillNearZ: 9.0, // spawn spacing is measured against bodies nearer than this drillSpread: 3.4, // m either side of the centre line the lanes fan across drillSpeed: 0.85, // m/s at the bottom of the ramp (x the type's vMul)... drillSpeedMax: 2.0,// ...and at the top. Stamped per body AT SPAWN — constant v is also what lets diff --git a/src/drill.js b/src/drill.js index fbf16bd..6b674a8 100644 --- a/src/drill.js +++ b/src/drill.js @@ -73,15 +73,19 @@ function pickType() { return pool[Math.floor(Math.random() * pool.length)]; } -// ---- THE STREAM. One body at a time, spaced (best-of-9 lanes against the back half — a retry loop +// ---- THE STREAM. One body at a time, spaced (best-of-9 lanes against EVERY live body — a retry loop // would stall the stream exactly when it should be getting harder), holding its lane all the way in. +// The measure used to ignore anything past the back half, which was wrong for a court where lanes are +// held for life and speeds are stamped per body: a lane 12 m down is still OWNED, and handing it to a +// faster body just books the overtake for ten seconds later, where it lands as two sprites walking +// through each other mid-court. A lane is free when its body is gone, not when it is far away. function spawnDrillBody(d) { - if (zombies.filter(z => z.drill).length >= TUNE.drillMaxOnCourt) return null; - const near = zombies.filter(z => z.drill && z.z < TUNE.drillNearZ); + const live = zombies.filter(z => z.drill); + if (live.length >= TUNE.drillMaxOnCourt) return null; let bx = 0, bs = -1; for (let k = 0; k < 9; k++) { const x = -TUNE.drillSpread + (2 * TUNE.drillSpread) * (k / 8) + (Math.random() - 0.5) * 0.5; - const s = near.length ? Math.min(...near.map(z => Math.abs(z.x - x))) : 1e9; + const s = live.length ? Math.min(...live.map(z => Math.abs(z.x - x))) : 1e9; if (s > bs) { bs = s; bx = x; } } const t = pickType(); From 131c0fa11440f43602bbcdaee133da0ceaefe276 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Sun, 16 Aug 2026 12:15:39 +0200 Subject: [PATCH 2/2] A rate gate needs its sample size derived from the rate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slid-serve test asked for 6 of 10 sliced serves returned. Measured over 200 trials a seed across eight seeds, the receiver's real rate is 0.85..0.92 — so the assertion was sound and the SAMPLE was too small: at p ~= 0.88, 10 trials land at or below 5 with probability ~0.004, one seed in 230, and SEED=80 was it. n is now 40 at the same 60% bar, which puts the threshold five standard deviations below the true rate while still failing instantly if the receiver regresses toward the 0-in-24 the test was born from. It is free: a returned serve breaks the frame loop early, and the suite still runs in ~0.11 s. Green on seeds 1..400. Co-Authored-By: Claude Opus 5 --- PROJECT.md | 17 ++++++++++++----- test.js | 8 +++++++- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index c0d6252..e67d9ab 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -431,11 +431,18 @@ Measured, 60 seeds × 150 s of drill each: before, **7 seeds produced overlaps, is still the cap of 7, so the fan is wider, not thinner. Suite green on 20260811 / 1234 / 777 / 99 / 424242 / 8, and on 299 of seeds 1..300 — the one straggler is unrelated and still open (see below). -**OPEN, found by the same fuzz:** `SEED=80` fails *"opponent one returns a slid serve: 5/10"*. That -assertion wants 6 of 10 sliced serves returned and the AI's real rate sits right on the threshold, so -it is a coin-flip gate of exactly the kind this file warns about. Untouched here because the honest -fix is a decision about the opponent, not the test: either widen `n` so the mean settles, or accept -5/10 as the real return rate and say so. +**The one straggler the same fuzz found, and the arithmetic to copy for any rate gate here.** +`SEED=80` failed *"opponent one returns a slid serve: 5/10"*. The receiver is innocent: measured over +200 trials a seed across eight seeds, the real return rate is **0.85..0.92**. The gate was simply too +small a sample — asking 6 of 10 at p ≈ 0.88 fails with probability ~0.004, one seed in 230, and 300 +seeds duly produced one. **n is now 40 at the same 60% bar**, which puts the threshold five standard +deviations below the true rate while still failing instantly if the receiver regresses toward the +0-in-24 the test was born from. It costs nothing — a returned serve breaks the frame loop early, so +the whole suite still runs in about a tenth of a second. Green on seeds 1..400. + +The general lesson: **a pass/fail gate on a random rate needs its sample size derived from the rate, +not guessed.** If the true rate is p and the bar is b, n has to put b several standard deviations +(`sqrt(p(1-p)/n)`) below p, or the gate is a slow coin flip that trains you to re-run the suite. ## Session log: THE NETPLAY LOBBY — a link to send, a code to type, a name to be (2026-08-15) diff --git a/test.js b/test.js index 073ee0e..bbd4b6e 100644 --- a/test.js +++ b/test.js @@ -1108,8 +1108,14 @@ test('a served ball with a slide on it is no longer a free kill', () => { } return false; }; + // n = 40, NOT 10, and this is the arithmetic to copy for any other rate gate here. The real return + // rate is ~0.88 (measured, 200 trials a seed across eight seeds: 0.85..0.92), so a 10-sample gate + // asking for 6 fails whenever the tail lands — p ~= 0.004, which is one seed in 230 and duly showed + // up as SEED=80. At 40 the same 60% bar sits five standard deviations below the true rate: the gate + // still fails instantly if the receiver regresses toward the 0-in-24 this test was born from, and + // it costs nothing (a returned serve breaks the frame loop early). let got = 0; - const n = 10; + const n = 40; for (let i = 0; i < n; i++) if (serve(-0.5)) got++; assert.ok(got >= n * 0.6, `opponent one returns a slid serve: ${got}/${n} (was 0)`); });