The rounds stay, the seams go: one continuous stream

Oliver: "daniel announcing is maximum annoying" / "don't let the rush in
position just let them coming from the back in steady speed.. try to space
them not too close" / "when one combination is done just let the new one in..
in a continuous stream that increases in speed and difficulty" / "the rounds
can stay, they should be continuous".

The round survives as the unit. Every seam around it is deleted: no walk-on
to a mark, no announcement beat, no between-rounds pause, no deal. Bodies
walk in from the far baseline forever and a round is drawn from whoever is on
court — finish one and the next is drawn in the same frame, off the same
rolling population. The bodies you were ignoring a second ago are the ones
being called now.

- Daniel is gone from this mode. Each colour has a pitch; a round announces
  itself as a four-note figure in half a second (blip()). drillTones 0 mutes
  it. The recordings stay in assets.
- PLACEMENTS is deleted with the marks — nothing rushes anywhere and nothing
  stops, so there are no shapes to place.
- A DRILL BODY HOLDS ITS LANE. updateZombies homes bodies onto the player's x
  past the net, so they arrived spread and bunched into a knot right in front
  of you; spawn spacing cannot survive that. Their lane is the puzzle here.
- One ramp on TIME SURVIVED drives speed, spawn gap and round length. Speed
  is stamped per body at spawn, so the ramp reads as newcomers outpacing the
  ones already walking, not the court accelerating under you.
- The empty-court watchdog is gone and nothing replaced it: with a stream
  there is no state to be stuck in. One line covers every case.

27 tests, rewritten around the stream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-11 19:06:48 +02:00
1 parent 435a470ec7
commit 1d49c4343b
9 files changed
+286 -287

No files matched your search

+142 -168
View File
@@ -1,41 +1,49 @@
// THE COACHING DRILL — the cognitive mode. A prototype, deliberately: it exists to answer one
// question ("is reading a colour order off small moving bodies TENSE or just NOISY?") and nothing
// else, so it keeps its own round counter and does not touch tennis scoring. Wire it to score when
// the answer comes back yes.
// THE COACHING DRILL — the cognitive mode. Second design, after Oliver played the first:
//
// The beat: the umpire calls a sequence out loud while bibbed bodies walk on and take their marks,
// then they come for you and you must put them down IN THE CALLED ORDER. Bodies whose colour was
// never called are DECOYS and must be left alone. Two faults and they take the point — the double
// fault is the sport's own rule for "you had two goes at it", and the recording already exists.
// "daniel announcing is maximum annoying"
// "don't let the rush in position just let them coming from the back in steady speed..
// try to space them not too close"
// "when one combination is done just let the new one in.. in a continuous stream that
// increases in speed and difficulty" / "the rounds can stay, they should be continuous"
//
// So the ROUND survives as the unit — a called combination, put down in order, counted — and every
// SEAM around it is gone: no walk-on to a mark, no announcement beat, no between-rounds pause, no
// deal. Bodies walk in from the far baseline at a steady speed for as long as the mode runs, and a
// round is drawn from whoever happens to be on court. Finish one and the next is drawn on the spot,
// out of the same rolling population — the bodies you were ignoring a second ago are the ones being
// called now. Speed, spawn rate and combination length all ramp with time survived, so the stream
// IS the difficulty curve.
//
// THE MODE TRANSITIONS ARE NOT HERE — startDrill/endDrill live in beats.js with every other one
// (see CLAUDE.md rule 3: one mode owns the court, and each start* refuses there). This file is the
// drill's own clock, its bodies and its rules. It imports no renderer: render.js reads DRILL_COLS
// and draws, which keeps the cycle out of it.
// (CLAUDE.md rule 3). This file is the stream, the round and the rules. It imports no renderer:
// render.js reads DRILL_COLS/PIP_FACES and draws.
import { TUNE } from './config.js';
import { G, P } from './state.js';
import { G } from './state.js';
import { zombies, spawnZombie } from './zombies.js';
import { voice, voiceSeq, sfx } from './audio.js';
import { logSwing } from './gameplay.js'; // runtime-only cycle (gameplay imports drill), same pattern as beats/zombies
import { voice, blip, sfx } from './audio.js';
// The bib colours. FOUR, not six: at 270 logical px across, a body at the far baseline is a
// thumbnail, and six hues that read fine in a swatch strip do not read at all on a walking sprite.
// Every entry carries `pips` as well as a colour — roughly 8% of men cannot separate the red from
// the green, and a mode whose whole input is "which colour is that" is unplayable for them on hue
// alone. The pip count is the redundant channel and it is drawn on the bib AND in the HUD strip.
// The bib colours. Six, and every entry carries `pips` as well as a hue: roughly 8% of men cannot
// separate the red from the green, and a mode whose whole input is "which colour is that" is
// unplayable for them on hue alone.
// `tone` is the colour's PITCH — see the arpeggio in newRound. **The recorded umpire reading the
// list aloud is DELETED**: at ~0.8 s a word he took four seconds to get through a list of four, and
// with rounds running continuously that is a man talking over the entire mode. Oliver: "daniel
// announcing is maximum annoying". A rising four-note figure says the same thing in half a second
// and does not wear out. (`drill_*.m4a` stay in assets — cheap to keep, and the call could come
// back for a slower variant of the mode.)
export const DRILL_COLS = [
{ key: 'red', voice: 'drill_red', col: '#e5342c', ink: '#2a0806', pips: 1, name: 'RED' },
{ key: 'blue', voice: 'drill_blue', col: '#2f6bff', ink: '#050f2c', pips: 2, name: 'BLUE' },
{ key: 'yellow', voice: 'drill_yellow', col: '#f2c318', ink: '#2e2202', pips: 3, name: 'YELLOW' },
{ key: 'green', voice: 'drill_green', col: '#27bd4a', ink: '#04240f', pips: 4, name: 'GREEN' },
{ key: 'purple', voice: 'drill_purple', col: '#9b3fd8', ink: '#1c0530', pips: 5, name: 'PURPLE' },
{ key: 'white', voice: 'drill_white', col: '#f2f0e8', ink: '#20201c', pips: 6, name: 'WHITE' },
{ key: 'red', col: '#e5342c', ink: '#2a0806', pips: 1, tone: 392, name: 'RED' },
{ key: 'blue', col: '#2f6bff', ink: '#050f2c', pips: 2, tone: 440, name: 'BLUE' },
{ key: 'yellow', col: '#f2c318', ink: '#2e2202', pips: 3, tone: 494, name: 'YELLOW' },
{ key: 'green', col: '#27bd4a', ink: '#04240f', pips: 4, tone: 587, name: 'GREEN' },
{ key: 'purple', col: '#9b3fd8', ink: '#1c0530', pips: 5, tone: 659, name: 'PURPLE' },
{ key: 'white', col: '#f2f0e8', ink: '#20201c', pips: 6, tone: 784, name: 'WHITE' },
];
// The pips are laid out as DICE FACES, not as a row. Counting five dots against a clock is work;
// recognising a die is not — and the whole redundant channel is pointless if reading it costs more
// time than reading the hue. Unit coords in a -1..1 box, scaled onto the bib and onto the HUD chip
// by the same table, so the two readouts can never drift.
// The redundant channel is drawn as a DIE FACE, not a row of dots: five dots have to be counted, a
// die is recognised — and a redundant channel is pointless if reading it costs more than the hue.
// Unit coords in a -1..1 box, scaled onto the bib and the HUD chip by the same table so the two
// readouts cannot drift.
export const PIP_FACES = [
[[0, 0]],
[[-0.6, -0.6], [0.6, 0.6]],
@@ -45,173 +53,139 @@ export const PIP_FACES = [
[[-0.7, -0.7], [0.7, -0.7], [-0.7, 0], [0.7, 0], [-0.7, 0.7], [0.7, 0.7]],
];
// WHERE THEY STAND, and it is the difficulty dial with the real headroom — a longer list is a memory
// test and memory tests fatigue, but a called order that fights the geometry is a fresh problem
// every round. Each shape returns lanes and depths in the same order the bibs were dealt, so the
// colour assignment is independent of the shape: the same five positions ask a different question
// depending on which of them the umpire calls first.
// Fractions, not metres — `lane` is scaled by TUNE.drillSpread and `depth` by the mark band, so the
// shapes keep their character if either knob moves.
const PLACEMENTS = [
{ key: 'fan', w: 3, lane: (i, n) => -1 + 2 * i / Math.max(1, n - 1), depth: () => Math.random() },
// everything bunched into one half: short lanes, near-identical angles, and the sidestep matters
{ key: 'cluster', w: 2, lane: (i, n, s) => s * (0.15 + 0.5 * i / Math.max(1, n - 1)), depth: () => 0.25 + Math.random() * 0.5 },
// TWO OR THREE SHARING A LANE at different depths. The ball hits the first body it touches, so a
// target behind another is unreachable until you move — this is the shape that makes feet matter.
{ key: 'column', w: 3, lane: (i, n, s) => (i % 2 ? s * 0.75 : s * -0.15) + (i > 2 ? s * 0.1 : 0), depth: (i, n) => i / Math.max(1, n - 1) },
// near/far alternating across the full width: the depth order and the left-right order disagree
{ key: 'stagger', w: 3, lane: (i, n) => -1 + 2 * i / Math.max(1, n - 1), depth: i => (i % 2 ? 0.75 : 0.1) + Math.random() * 0.2 },
// all of them down one wing, one lonely outlier on the other — the outlier is often the one called
{ key: 'wing', w: 2, lane: (i, n, s) => i === 0 ? -s * 0.9 : s * (0.2 + 0.6 * i / Math.max(1, n - 1)), depth: () => Math.random() },
];
const pickPlacement = () => {
const pool = PLACEMENTS.flatMap(pl => Array(pl.w).fill(pl));
return pool[Math.floor(Math.random() * pool.length)];
};
// ---- THE RAMP. One number, 0..1 off time survived; everything else is a lerp on it. The stream is
// the difficulty curve, so this is the only dial that matters — and it replaces the round-counted
// escalation the first design used, which could not escalate while you were standing still.
const ramp = d => Math.min(1, d.t / TUNE.drillRampT);
const lerp = (a, b, k) => a + (b - a) * k;
export const drillSpeed = d => lerp(TUNE.drillSpeed, TUNE.drillSpeedMax, ramp(d));
const spawnGap = d => lerp(TUNE.drillSpawnEvery, TUNE.drillSpawnMin, ramp(d));
const comboLen = d => Math.round(lerp(TUNE.drillSeq, TUNE.drillSeqMax, ramp(d)));
const shuffle = a => { for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; };
const bibs = () => zombies.filter(z => z.bib !== undefined);
// How long the drill has been running decides how hard it is. Sequence length climbs first and
// then STOPS (TUNE.drillSeqMax): a longer list is a memory test, and memory tests fatigue — what
// keeps getting harder is the geometry, i.e. how often the called order fights the order the
// bodies are actually going to reach you in. That is the dial with the headroom.
const seqLen = r => Math.min(TUNE.drillSeqMax, TUNE.drillSeq + Math.floor(r / 3));
// ---- THE STREAM. One body at a time, from the far baseline, at whatever the ramp currently says.
// Two rules, both Oliver's: a STEADY walk in — nothing rushes to a mark and then stands there — and
// they must not arrive on top of each other.
function spawnDrillBody(d) {
const onCourt = bibs();
if (onCourt.length >= TUNE.drillMaxOnCourt) return null;
// A colour is on court at most ONCE. Two reds and "red" stops naming a body, which makes the
// round ambiguous rather than hard.
const free = DRILL_COLS.map((_, i) => i).filter(i => !onCourt.some(z => z.bib === i));
if (!free.length) return null;
// SPACING, per "try to space them not too close". Sample lanes across the width and take the one
// standing furthest from everyone already in the back half. Best-of-N rather than a retry loop, so
// a busy court still spawns — a hard minimum would silently stall the stream at exactly the moment
// it is supposed to be getting harder, which reads as the mode dying.
const near = onCourt.filter(z => z.z < TUNE.drillNearZ);
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;
if (s > bs) { bs = s; bx = x; }
}
spawnZombie(bx, TUNE.drillSpawnZ + Math.random() * 0.8, Math.random() < 0.25 ? 'b' : 'a');
const zb = zombies[zombies.length - 1];
zb.bib = free[Math.floor(Math.random() * free.length)];
zb.drill = true;
// Speed is stamped at SPAWN and never re-read: a body that walked in while the drill was gentle
// keeps its gentle pace all the way, so the ramp arrives as NEW arrivals moving faster than the
// ones already there, rather than as the whole court accelerating out from under you.
zb.v = drillSpeed(d) * (0.92 + Math.random() * 0.16);
return zb;
}
export function newDrillRound() {
// ---- THE ROUND. Drawn from whoever is standing there, the instant the last one is finished. No
// pause, no deal, no announcement: the strip changes and the arpeggio plays.
function newRound(d) {
const pool = bibs();
d.step = 0; d.faults = 0;
if (pool.length < 2) { d.seq = []; return; } // not enough on court yet — the stream will fill it
const k = Math.max(2, Math.min(comboLen(d), pool.length));
d.seq = shuffle(pool.map(z => z.bib)).slice(0, k);
// THE CALL, without the man: one short note per colour, in the called order. Half a second for the
// whole figure. TUNE.drillTones 0 plays it silent.
if (TUNE.drillTones > 0)
d.seq.forEach((b, i) => blip(DRILL_COLS[b].tone, i * 0.13, 0.22 * TUNE.drillTones));
}
// Called by beats.startDrill once the court is its own.
export function startDrillStream() {
const d = G.drill; if (!d) return;
zombies.length = 0;
// HOW MANY varies too. With a fixed four out of a four-colour palette, every round put every
// colour on court and the only thing that changed was the order — which is three quarters of a
// round already known before it starts. The palette is SIX and the court takes three to five, so
// which colours turn up is a real question again and the decoy count moves with it.
const n = Math.min(DRILL_COLS.length,
TUNE.drillBodiesMin + Math.floor(Math.random() * (TUNE.drillBodies - TUNE.drillBodiesMin + 1)));
const k = Math.min(n, seqLen(d.won + d.lost));
// Every body on court wears a DISTINCT bib, so "the red one" is never ambiguous. The sequence
// names k of them; the other n-k are decoys, and a decoy is a body actively walking at you that
// you are required to ignore. That is where the pressure comes from, and it costs nothing.
const bibs = shuffle(DRILL_COLS.map((_, i) => i)).slice(0, n);
d.seq = shuffle(bibs.slice()).slice(0, k);
d.step = 0; d.faults = 0; d.beat = 0;
d.callT = TUNE.drillCallT;
// The SHAPE is drawn per round and the lanes are shuffled against the bib order, so the called
// order is unrelated to both the left-right order and the depth order. `side` mirrors the
// asymmetric shapes so cluster/wing/column do not always favour the same half of the court.
const pl = pickPlacement(), side = Math.random() < 0.5 ? -1 : 1;
d.shape = pl.key; // for the miss log — "which shape was that" is the first question
const slots = shuffle(bibs.map((_, i) => i));
bibs.forEach((bib, i) => {
const slot = slots[i];
const x = Math.max(-TUNE.drillSpread, Math.min(TUNE.drillSpread, pl.lane(slot, n, side) * TUNE.drillSpread));
const z = TUNE.drillMarkZ + pl.depth(slot, n) * TUNE.drillMarkSpread;
spawnZombie(x, 1.2 + Math.random() * 1.6, Math.random() < 0.25 ? 'b' : 'a');
const zb = zombies[zombies.length - 1];
zb.bib = bib;
zb.drill = true;
zb.v = 0; // updateDrill owns their movement for the whole round
zb.mark = { x, z };
});
voiceSeq(['drill_call', ...d.seq.map(b => DRILL_COLS[b].voice)]);
d.seq = []; d.step = 0; d.faults = 0; d.t = 0; d.spawnT = 0; d.rounds = 0; d.lost = 0; d.msg = null;
// Seed the court, strung out in depth, so the first round is callable at once instead of after the
// stream has trickled three bodies in.
for (let i = 0; i < TUNE.drillSeed; i++) {
const zb = spawnDrillBody(d);
if (zb) zb.z += i * TUNE.drillSeedGap;
}
newRound(d); // called HERE, not on the first update: the mode has to be whole the moment it starts,
// or the strip draws empty for a frame and anything reading seq straight after
// startDrill() sees a drill with no round in it.
}
// THE ROUND CLOCK. Drill bodies carry v = 0 so the ordinary shamble in updateZombies leaves them
// alone; everything else in that loop — the ball contact test, the instant-death contact test —
// still runs against wherever this puts them, which is exactly the split we want.
export function updateDrill(dt) {
const d = G.drill; if (!d) return;
d.t += dt;
if (d.msg) d.msg.t -= dt;
if (d.beat > 0) { if ((d.beat -= dt) <= 0) newDrillRound(); return; }
// THE EMPTY-COURT NET. A drill round is only ever ended by drillWon/drillLost, both of which set
// `beat` — so a live round with nothing bibbed on court means something OUTSIDE the drill emptied
// it. I saw this exactly once while wiring the mode up (a warm-up had left the ordinary schedule's
// latches loaded) and could not reproduce it afterwards, which is precisely the kind of bug that
// comes back in front of a player. Unrepaired it is a dead mode: no targets, no timer, nothing to
// tap. Deal a fresh round and leave evidence, same contract as watchdog()'s invariant net.
// It goes through the ordinary between-rounds BEAT rather than dealing on the spot, and that is
// the load-bearing half: dealing here directly makes the repair rate the FRAME RATE, so a court
// that keeps coming up empty redeals sixty times a second — and every deal calls the umpire.
// Through the beat, the worst case is one redeal every drillBeat seconds, which is a mode that
// limps instead of a phone screaming at you.
if (!zombies.some(z => z.bib !== undefined)) {
logSwing({ result: 'INVARIANT', reason: 'drill court emptied from outside the round — redealing' });
d.beat = TUNE.drillBeat;
return;
}
if (d.callT > 0) { // the walk-on: they take their marks
d.callT -= dt;
for (const zb of zombies) {
if (!zb.mark) continue;
const dz = zb.mark.z - zb.z, dx = zb.mark.x - zb.x;
const step = TUNE.drillWalkOn * dt;
zb.z += Math.sign(dz) * Math.min(Math.abs(dz), step);
zb.x += Math.sign(dx) * Math.min(Math.abs(dx), step * 0.7); // they drift into the shape, not snap to it
zb.dist = (zb.dist || 0) + Math.min(Math.abs(dz), step);
zb.walkT += dt;
}
return;
}
const v = TUNE.drillSpeed * (1 + (d.won + d.lost) * TUNE.drillSpeedPerRound) * (d.rush > 0 ? TUNE.drillRush : 1);
if (d.rush > 0) d.rush -= dt;
for (const zb of zombies) {
if (!zb.drill) continue;
zb.z += v * dt; zb.dist = (zb.dist || 0) + v * dt; zb.walkT += dt;
}
// the stream never stops — this is the whole mode
if ((d.spawnT -= dt) <= 0) { spawnDrillBody(d); d.spawnT = spawnGap(d) * (0.8 + Math.random() * 0.4); }
// A round is owed whenever there isn't one: at the start, after a completion, after a double
// fault, and after everything named was killed by something else. This one line replaces the whole
// empty-court watchdog the round-based version needed — with a stream there is no state to be
// stuck in, because the next round is always just "whoever is out there now".
if (!d.seq.length) newRound(d);
// (movement is updateZombies' ordinary forward step: drill bodies carry a real zb.v now, so they
// shamble in like anything else and their stride animates off distance covered for free. The first
// design drove them by hand because they had marks to stop at. Nothing stops any more.)
}
// A drill body went down. The ONE rule of the mode, and it is called from killZombie — the single
// kill funnel — so it cannot be missed by a weapon path (the bug this codebase has had twice).
// A drill body went down. Called from killZombie — the single kill funnel — so no weapon path can
// miss it (the bug this codebase has had twice).
export function drillKill(zb) {
const d = G.drill; if (!d || zb.bib === undefined) return;
if (zb.bib === d.seq[d.step]) {
d.step++;
sfx('pock', 0.7);
if (d.step >= d.seq.length) return drillWon();
if (d.step >= d.seq.length) { // round complete — and the next one is already here
d.rounds++;
d.msg = { text: 'GOOD', t: 0.9 };
newRound(d);
}
return;
}
// Wrong body — a decoy, or one that was due later. A body killed out of turn is still DEAD, so
// drop it from the list rather than leaving a slot nobody can ever fill: an un-winnable round
// reads as the game being broken, and the fault below is the punishment already.
// Wrong body — one never called, or one due later. It is dead either way, so drop it from the list
// rather than leaving a slot nobody can fill: an un-winnable round reads as a broken game, and the
// fault below is the punishment already.
d.faults++;
d.seq = d.seq.filter((c, i) => i < d.step || c !== zb.bib);
d.rush = TUNE.drillRushT; // and the rest of them lose patience — the cost is TEMPO
if (d.faults >= 2) return drillLost('double_fault', 'DOUBLE FAULT');
if (d.faults >= 2) {
d.lost++;
voice('double_fault', 0.1);
d.msg = { text: 'DOUBLE FAULT', t: 1.6, bad: true };
return newRound(d); // scrubbed, and a fresh one off the same court
}
voice('fault', 0.1);
d.msg = { text: 'FAULT', t: 1.4, bad: true };
if (d.step >= d.seq.length) drillWon(); // the wrong kill happened to be the last one outstanding
d.msg = { text: 'FAULT', t: 1.2, bad: true };
if (d.step >= d.seq.length) { d.rounds++; newRound(d); } // the wrong kill was the last one outstanding
}
// A drill body reached you. Called from killPlayer, which is the single death path for BOTH damage
// channels — so this intercepts every way the round can end badly, in one place.
// NOTE, and it is a real design decision rather than an oversight: contact here costs the POINT,
// not the run. Permadeath is settled for the gore mode and it should stay there; a memory game
// where one lapse of concentration deletes a thirty-minute run punishes the exact skill it is
// trying to teach, and the retry loop is the whole reason a drill is fun.
// A drill body reached you. Called from killPlayer, the single death path for BOTH damage channels.
// Contact costs the POINT and a cleared court, never the run: permadeath is settled for the gore
// mode, and a memory game where one lapse deletes a long run punishes the skill it is teaching.
// This is the ONE thing that interrupts the stream, which is exactly what makes it read as a setback.
export function drillCaught() {
drillLost('game_opponent', 'CAUGHT');
}
function drillWon() {
const d = G.drill;
d.won++;
voice(d.faults ? 'game_player' : 'unbelievable', 0.15);
d.msg = { text: d.faults ? 'GAME' : 'CLEAN SHEET', t: 1.8 };
d.beat = TUNE.drillBeat;
zombies.length = 0;
}
function drillLost(line, text) {
const d = G.drill;
d.lost++;
voice(line, 0.1);
d.msg = { text, t: 1.8, bad: true };
d.beat = TUNE.drillBeat;
voice('game_opponent', 0.1);
d.msg = { text: 'CAUGHT', t: 1.8, bad: true };
zombies.length = 0;
d.seq = []; d.step = 0; d.faults = 0; d.spawnT = 0;
d.t = Math.max(0, d.t - TUNE.drillCaughtBack); // ...and the ramp backs off a little
}
// Which body the shot is allowed to lock onto — see flickTarget. During the call nothing is a
// target yet (you cannot serve then either).
export const drillTarget = () => {
const d = G.drill;
if (!d || d.callT > 0 || d.beat > 0) return null;
return zombies.find(z => z.bib === d.seq[d.step]) || null;
};
// Which body is owed right now — the HUD highlights it. NOT used for aiming: the auto-aim is gone.
export const drillTarget = () => zombies.find(z => G.drill && z.bib === G.drill.seq[G.drill.step]) || null;