The umpire reads a list, and the bibs come out to meet it

The late game is repetitive because the optimal move never changes: kill the
nearest thing, now. This is the first swing at a mode where it does — a
coaching drill where the umpire CALLS a colour sequence aloud while bibbed
bodies take their marks, and you must put them down in that order while they
walk at you. Uncalled colours are decoys you have to ignore. Two faults is a
double fault and the round is theirs.

Back room only (DRILL TEST, or #drill): this is a feel-test for one question —
tense, or just noisy? — so it keeps its own round count and touches neither
tennis scoring nor the run.

- voiceSeq: voice() drops a line that would start while the umpire is still
  talking, which is fatal for a list. Decode first, queue at accumulated
  offsets, and the anti-overlap rule is satisfied instead of fought.
- Four colours with PIPS as well as hue — red/green alone makes the mode
  unplayable for ~8% of men.
- drawBib is a ponytail-marked overlay that knowingly breaks the sprite rule;
  it is the thing to delete if the mode survives.
- BOTH target pickers snap (flickTarget and zombieTarget). Snapping only the
  slingshot left fifteen tap-serves in a row scoring nothing.
- Contact costs the point, not the run — permadeath stays in the gore mode.
- startDrill disarms the ordinary schedule; updateDrill redeals and logs an
  INVARIANT if anything empties its court.

Eight new tests (24 total) pin the ownership contract both ways, the call
beat, the win, the double fault, the un-winnable-round guard and the death.
PROJECT.md carries the four open questions for the playtest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-11 18:01:33 +02:00
1 parent c659b6882f
commit 22df96cc1d
17 files changed
+556 -19

No files matched your search

+162
View File
@@ -0,0 +1,162 @@
// 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 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.
//
// 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.
import { TUNE } from './config.js';
import { G, P } 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
// 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.
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' },
];
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; };
// 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));
export function newDrillRound() {
const d = G.drill; if (!d) return;
zombies.length = 0;
const n = Math.min(DRILL_COLS.length, TUNE.drillBodies);
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;
// Distinct lanes, spread wide, shuffled against the bib order — so the called order and the
// left-to-right order are unrelated, and so are the called order and the DEPTH order. Two bodies
// sharing a lane occlude each other (the ball hits the first thing it touches), which is the
// footwork half of the mode: sometimes the only way to reach your target is to sidestep first.
const lanes = shuffle(bibs.map((_, i) => -TUNE.drillSpread + i * (2 * TUNE.drillSpread / Math.max(1, n - 1))));
bibs.forEach((bib, i) => {
spawnZombie(lanes[i], 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: lanes[i], z: TUNE.drillMarkZ + Math.random() * TUNE.drillMarkSpread };
});
voiceSeq(['drill_call', ...d.seq.map(b => DRILL_COLS[b].voice)]);
}
// 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;
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.
if (!zombies.some(z => z.bib !== undefined)) {
logSwing({ result: 'INVARIANT', reason: 'drill court emptied from outside the round — redealt' });
return newDrillRound();
}
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;
zb.z += Math.sign(dz) * Math.min(Math.abs(dz), TUNE.drillWalkOn * dt);
zb.dist = (zb.dist || 0) + Math.min(Math.abs(dz), TUNE.drillWalkOn * dt);
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;
}
}
// 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).
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();
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.
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');
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
}
// 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.
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;
zombies.length = 0;
}
// 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;
};