Commit Graph
95 Commits
Author SHA1 Message Date
wuwiandClaude Fable 5 1facc954e2 HARD hunts the corner you are not standing in
"the hard mode is still too easy — make it really challenging." The dial that
was missing is aim: the CPU rolled a random lane on every shot, so a third of
everything came politely back to your racket regardless of the other six
numbers. DIFFS grows `smart` — the probability the CPU plays the OPEN court,
the corner away from P.x — and HARD sets it to 0.8: four shots in five now
make you run. EASY and MEDIUM keep random aim (MEDIUM pinned by test), so
the shipped classic is untouched below the top chip.

The rest of HARD tightened in the same pass: reaction 0.22 s, speed 1.30,
reach 1.85 m, quality floored at 0.70 under a cap of 1.0, serve 1.15, flub
one ball in a hundred. It reads your shot almost instantly, covers what it
reads, and places its reply where you are not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 12:02:49 +02:00
wuwiandClaude Fable 5 77bc68043d Difficulty moves the dials a player can feel: reaction, reach, floor, serve
"using the difficulty in classic tennis I don't feel any difference" — and he
was right, verified live: G.diff arrives intact, the dials were just the
wrong ones. flub/cap/speed barely register: the quality cap trims a
distribution whose 0.45 floor never moved (EASY and HARD averaged 0.59 vs
0.70), and ±12% on aiSpeed never binds because the CPU pre-plans its
intercept. What a player reads as SKILL is how fast the CPU reacts to the
shot (aiReaction — the config's own comment says corners are winners because
of it), how long its racket is (aiReach), the FLOOR of its shot quality (a
raised qMin means every ball comes back with pace), and the serve — none of
which the first cut touched, and the serve was hardcoded 0.6-0.95 besides.

DIFFS carries seven dials now with real spread: EASY whiffs one ball in
three, reads late, swings short and serves soft; HARD reads in 0.28s, swings
1.75m, floors quality at 0.62 with a cap of 1.0, and serves 10% hotter.
MEDIUM is pinned dial-for-dial to the shipped TUNE values by test, so the
middle chip remains exactly yesterday's classic. All G.diff-guarded, so the
racket zombie and gore's opener keep their own numbers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 11:50:44 +02:00
wuwiandClaude Fable 5 e4c917e23d The menu grows a second step, classic learns difficulty, and the rally feel unifies
Oliver's restructure: mode first, then what the mode asks — classic deals
EASY / MEDIUM / HARD chips, ZOMBIE TENNIS deals two sub-mode posters (ARCADE,
the exhibition ladder as was, and THE TOURNAMENT), and SPLATTER GORE stands
alone as the third top card. config.MENU is the grouping, MODES stays the four
run modes, and pickedMode stays the run-mode id — start(), stats and the
summary never learn the menu got taller. The sub-row sits inset under the
posters; sub-cards are the poster at 2/3 height centred on their scene.

DIFFICULTY is the one real gameplay addition: config.DIFFS scales the three
dials the classic CPU already reads (flub, quality cap, aiSpeed), MEDIUM
pinned by test to the shipped classicFlub/classicQualityMax so the middle chip
IS yesterday's game. Rides on G.diff next to G.classic, so restarts keep it.

AND THE FEEL SPLIT OLIVER REPORTED IS REAL: "in classic tennis I tend to miss
balls way more often" — the match's three contact helpers (matchReach 1.35,
the matchAssist floor, the short-ball chase) were all G.match-gated, so
classic played with a 35% smaller racket and no rescue on the dying drop
shot. rallyFeel() = match || classic now carries all three; gore's opener
keeps the old prologue feel on purpose.

The ARCADE poster took two rolls: the first came back a flat sticker line-up
("very constructed, not artsy"), the second anchors on the zombie poster
itself as a MOOD reference — one challenger lit at the net, the queue receding
into purple haze. Rolls in _refcheck/modebtn. The cup's stats mapping also
joins MODE_MATCH (it banks through endRun as MATCH and read back as GORE).
SPRV 117, main.js v233, 66 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 10:29:51 +02:00
wuwiandClaude Fable 5 129a001109 The latch is stale-proof by construction, the cup divisor honest, the trail visible
Oliver's tournament freeze was the stale latch by a THIRD route:
matchFallWillPlay kept the deathmatch /4 while cup matchPoint divides by
4*need, so it promised a fall on a no-crossing point — whose synchronous
matchReady left the after-set latch scoping a dead rally. No more ordering
arguments: the latch is set BEFORE the resolve, the swallow exempts the
in-progress resolution via G.matchEarly, and every path that arms a rally
clears it in matchReady. willFall now mirrors the cup divisor exactly and
knows a mid-round game keeps its body. Regression test drives the full QF
sequence (no-crossing points, the fall, the mid-round game, game two).

And the two 'not really' reports: the trail's 0.26 alpha was tuned on the
gore palette and vanished on the bright lawn — 0.38 now, gate at 13 m/s;
oppBright 1.18 -> 1.3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 22:36:44 +02:00
wuwiandClaude Fable 5 f9b26e5380 THE TOURNAMENT, the pace-lit trail, and brighter opponents
The fourth mode: four rounds up the dead hierarchy (CUP_ROUNDS), each round
drawing a rung from its band, the champion always the final. It rides the
match machinery whole — cupDraw feeds the drawn rung through d.opp so every
ladder dial scales untouched; the body's pool spans the round (need-scaled
pool and divisor, so wounds carry between games); cupGameWon holds the same
body mid-round, kills it on the round, and past the final cupWon banks a
NOT-dead match run under CHAMPION OF THE DEAD CLUB. Blood carries; round one
is a single game; deathmatch stays as the arcade run. Full-run state test.

Sidequests: the ghost trail is pace-qualified (TUNE.trailSpeed) so both
tennis modes streak — pow>0.8 was only ever reached by the gore slingshot;
opponent sprites draw ~18% brighter via a load-time baked copy
(render.brighten, TUNE.oppBright) with no PNG touched; the cup scoreboard
line names the round instead of lying with deathmatch arithmetic; the rack
and the apron demo no longer draw under a trophy screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 22:20:10 +02:00
wuwiandClaude Fable 5 1e601ca33d The fall drops fast, lies long, and never rises
Oliver, three refinements in one breath: the frame PROGRESSION quicker
(drop 0.06+0.25 -> 0.05+0.15 — against a live rolling ball the slower drop
read as mush), the prone hold KEEPS its bench-tuned 2 s ('the lying itself
can stay that long'), and the rise row is cut ('the opponent respawns
anyway') — done() re-marks the body in its wound skin regardless, so rising
first was a respawn with extra steps. The rise frames stay in the sheets
for the bench. Wound-beat test updated to the new contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:58:32 +02:00
wuwiandClaude Fable 5 f991f1ab10 Fix the early-fall soft-lock: the decided latch must die with its rally
Oliver's retest: 'the fall never happens, the game does not continue, the
ball is gone.' Root cause: the decided latch was set AFTER resolveInterlude,
and on a point with no stage change matchPoint re-arms the next rally
synchronously — so the latch outlived the rally it scoped and swallowed
every resolution after it.

Two changes: matchFallWillPlay() gates the early resolution to points that
actually put a collapse (or the game-winning kill) on court — an ordinary
point keeps its ordinary bounce-2 ending, so the ball never vanishes
mid-roll — and with only those paths admitted, the after-set latch is
provably safe: both defer matchReady until their beat ends. A regression
test drives the exact soft-lock sequence through the real update loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:52:44 +02:00
wuwiandClaude Fable 5 53df079db6 The fall beats the ball: a passing winner fells the body while it still rolls
Oliver: 'the zombie falls after the ball is lost although we know this
earlier — it could play out as ball lost because of the falling.' The match
point now resolves at bounce ONE when the winner is provably beyond the
body's reach (closed-form flight vs its own chase dials, full swing reach
plus 0.6 m of doubt — borderline balls wait for the ordinary bounce-2 call).
startFall keeps the ball rolling for an early-decided point, and the
d.decided latch swallows the rally's later resolutions so it can never bank
twice. Verified in-pane: collapse starts ~0.3 s after the bounce, ball rolls
past the prone body, score banks exactly once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:35:52 +02:00
wuwiandClaude Fable 5 c6218a6284 Exact touchdown, the boy finishes his shift, healing announces itself, 4K heads
Five of Oliver's six (the sixth is a play-mode discussion):
- THE DOUBLE FAULTS WERE FRAME QUANTIZATION: the bounce registered a full
  integration step after true contact — ~0.75 m of travel at serve pace — so
  serves aimed half a metre safe were judged long. Classic enforces the
  service box, the match only the court, hence 'zombie tennis does not'.
  The bounce now rewinds to y=0 before anything reads the position.
  Measured in-pane: 30/40 faults on good taps before, 0/40 after (mistimed
  and smudged taps also 0/40; aimed hard serves keep their scatter risk).
  Also: the strike flick dead zone is 44 px so a smudged tap stays a tap,
  and the aim base clamps 0.18 m inside the lines — scatter, not the aim,
  is what faults.
- The mop beat holds the walk-on until the boy actually LEAVES the court
  (ordered off at beat end, 6 s cap).
- A match game win that restores blood shows the duel's WOUNDS TREATED
  popup; full-blood wins stay silent.
- The opponent walk-on card holds 4.0 s (was 2.4).
- Card heads are cut from the 4K sheets at 144 px (assets/_heads.py,
  z_<fam>head.png, HEAD_CROP stays as fallback) — the old 65 px crops were
  the 'very low res' heads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:09:04 +02:00
wuwiandClaude Fable 5 9a49acff51 The twelve-rung ladder: director, champion, physio and mascot walk on
Oliver's four picks, each rolled off its own settled BOSS frame through the
family pipeline (rows in _famroll.py) — the rung and the chapter boss are the
same person. matchFoe decoupled from bossArt's cycling on his call: the match
ladder is 12 with the champion capping rung 12; chapters keep their ten.

The champion refused to bulk through four rolls (words lost, then a new
pair-reference in char_ref only half-won), so his mass is stature: OPPONENTS
rows carry an optional world height h read by zombieH — champion 2.1 m, the
Bulk 2.0 m — and the serve aims at the body actually drawn. HEAD_CROP measured
for all four; ramp test extended to 12 rungs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 18:37:43 +02:00
wuwiandClaude Fable 5 de77ccfea5 The serve gets stakes: tight apex window, depth rides quality, the strike flick aims
Three mechanical causes of 'no serve agency', measured then fixed:
- the 0.12 m perfect snap was ~0.3 s of hang time (now TUNE.apexSnap 0.05,
  and the gold band reads the same knob);
- the fixed mid-box target depth capped every good serve at ~36 m/s in the
  loft loop — player serve depth now rides quality, flush aims 0.3 m inside
  the service line (ladder: 49 / 52 hard / 36 mistimed / 30 bad);
- the strike flick was splatter-only — in tennis and the duel it now sways
  placement (horizontal) and buys pace (vertical, serveHard + serveHardSpin,
  the extra dive IS the clearance), paid for in scatter against the real
  fault machinery. Tap stays the safe second serve.

Phone verdict pending — see the PROJECT.md session log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 17:23:07 +02:00
wuwiandClaude Fable 5 c084d53497 Serve reads, the mop beat, the head-icon card, and Daniel benched
Oliver's batch, the code half:
- The tennis serve lost its ghost fingers (idle tap demo + apex rider deleted
  with their counters; the rally-return hint survives).
- The toss rises over the tossing HAND, measured off the toss frames' raised
  fingertips (TOSS.dx/oDx, women's tour override) — not over the head.
- An uncaught toss returns to the bounce stance instead of flashing the smash
  frame the player never earned.
- Between match games the court belongs to the mop boy: 5 s (TUNE.matchMopT),
  full job allowance, a hurry speed between stains, and the far-court reach
  limit lifted for the match (his stains are exactly where opponents die).
  The next opponent walks on only after — d.ending guards the empty court.
- The walk-on card wears the opponent's own head: config.HEAD_CROP, straight
  crops measured off the family sheets, no new art.
- The match intro is DEATH MATCH, counting the real blood pool.
- Daniel is benched: every ump_* personality line routes through aside()'s
  early return; officiating calls keep speaking. Personality pass later.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 17:10:45 +02:00
wuwiandClaude Opus 5 eb0e8775a4 The timing cues were match-only, and the walk cycles bounced
CLASSIC TENNIS, third time asked and this time actually found. Both cues open
with a mode gate and I checked the wrong two features twice: drawApproachRing
(the closing circles that show when to tap) and drawApexBand (the serve bar
with the gold perfect window) each began `if (!G.match ...)`. So the mode whose
whole pitch is "if the core tennis is good everything else is good" was the one
mode with neither. They run on `G.match || G.mode === 'tennis'` now — the gore
mode stays out on purpose, there is no rally there. A test reads both function
bodies and fails if either is ever gated on the match alone again.

(The two I kept pointing at — the landing ring and the placement ring — really
are shared. They were just not the ones being asked about.)

THE BOUNCING WAS IN THE ART, and measurable: the six generated families came
back with the shuffle frame 30-44 px shorter than the stance it walks out of,
against 9 px on the hand-made racket family. The game draws every frame at its
own PNG height, so the body rose and dropped once per step. A walk cycle is one
drawing with the legs moved and must be one height; _zsheet levels the stance
and the stride pair now and leaves the forehand its own, exactly as the serve
and the fall keep theirs. All seven families re-cut: spreads are 1-8 px.

THE BENCH GETS BUTTONS. Arrow keys are not a control scheme on a phone, which
is the device the walk has to be judged on — so every family and every wound
stage was unreachable from the place the problem shows. Three on-screen pads:
BODY, WOUND, POSE, the two-way ones split left/right. Geometry lives beside the
drawing and input hit-tests the same function, the rule the rack established.

THE FALL is quicker still (0.05/0.07/2.0/0.30): drop, lie there, get up.

Three umpire lines pulled — ump_wound_you, ump_felled, ump_wound_opp2. Not
funny enough to keep. Files deleted, call sites collapsed to the two that stay.

60 tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 20:38:50 +02:00
wuwiandClaude Opus 5 e1ce2486c0 SERVE UP was dropping you into gore whatever you picked
THE MENU BUG, and it explains three reports at once. cStart's handler was
`addEventListener('click', start)` — and addEventListener hands a listener the
MouseEvent as its first argument, so `start(modeId = pickedMode)` never saw its
default: modeId was an EVENT OBJECT. `G.classic = modeId === 'classic'` was
therefore always false and the zombie branch never fired. Picking CLASSIC or
ZOMBIE TENNIS on the setup screen did nothing at all; SERVE UP always started
the gore mode. A default parameter only applies to `undefined`, and a click
handler always passes something. `() => start()`.

That is also why "in normal tennis i dont see the ball circles and the serve
hint bar": there was no normal tennis to be in. Both are drawn unconditionally
in render() and the tennis struggle hints are already mode-gated correctly.
Verified through the real menu after the fix: CLASSIC gives mode=tennis with
no bodies on court, ZOMBIE gives match=true against the SHAMBLER in its own
body. Every other bare handler in the codebase takes no parameters — checked.

THE MOP BOY had the same class of bug one layer down. sendBoy was called and
mopWorthIt() refused: it tests `d.z >= COURT.netZ - 2`, the NEAR half, because
in the gore mode everything dies on the near court. A match's blood is all on
the FAR side, where the body stands and bleeds through four wound stages, so
the boy could never qualify. The near-side bias stays; the match is the
exception.

DANIEL was being dropped, not missing. Two causes, both mine: the asides were
queued at 0.7-1.5 s and matchPoint fires the score call at 0.4 s with a clip
over a second long — voice() drops anything that would start while he is still
talking (one man, one microphone). And asideEvery is 55 s, a whole match game.
Delays moved past the score call; matchAsideEvery is 15 s, which fits a mode
whose events are a limb and your blood rather than a wave of bodies.

THE FALL: quicker down, longer on the floor (0.09/0.11/2.0/0.30). And it was
genuinely SHRUNKEN — the generator draws fall rows smaller than standing rows
(the brute's buckle came back 190 px against a 224 px stance) and _zsheet's one
global scale preserved that faithfully, so the body shrank the moment it
started to fall. A fall row is anchored on its own buckle now, which must draw
at standing height. All seven generated families re-cut: 224/153/98/181 against
the hand-made racket family's 224/154/85/179 — the convergence is the check.

THE FLICK: 0.42 made going out impossible, which fixed the 8-in-10 bug and
overshot it ("too easy. i must be able to play out"). 0.62 puts risk back where
it belongs — ~15% out at quality 0, 5% at 0.75, and never on a perfect flick.
The test now asserts that shape: bounded, monotonically falling, zero at 1.

59 tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 20:07:02 +02:00
wuwiandClaude Opus 5 52b38e4a39 Eight bodies on the card, and the umpire gets his voice back
THE FLICK, again. "i can barely reach the extremes of the court." It could
reach 3.3 m against a 4.115 m line — safe, and too safe. TUNE.flickWidth is
3.95 now (96% of the way to the corner, 17 cm to spare) with flickReach 0.42,
which keeps the guarantee intact: reach x timing + scatter stays inside the
line at EVERY quality, worst case 4.06. 20% more reach, still impossible to
send out by aiming.

A FORWARD FLICK IS A SMASH. Up-screen is the far court, so flicking into it
asked for depth and nothing else — a direction with no consequence behind it.
It buys pace now (smashBoost, scaled by timing). Deliberately NOT distance:
solveShot still lands it on the same spot, so the shot arrives flatter and
sooner rather than longer. A smash you can hit out on a whim is just the old
bug wearing a hat.

THE FALL HOLDS. "the opponent should fall and then lie there for a quick
moment, the player stands up too quick." fall3 goes 0.30 -> 0.62. A corpse
that touches the ground and is instantly upright reads as a stumble.

THE CARD. Ten opponents, six of them NEW bodies off the one-sheet pipeline
(shambler, ball boy, steward, runner, groundskeeper, line judge), with the
club pro's own body and the brute woven in as 7 and 8. It is a ladder of
STAFF: the further you get, the further up the club the thing across the net
used to be. Narrative and difficulty are the same list read twice, and a test
walks all ten — whiff must fall every rung, names must fit the board's 8-char
slot, speed may zigzag by character but never regress.

Steeper per rung (speedPer .08->.11, pacePer .035->.05, flubKeep .82->.76):
"in general this is too easy now". With aimed returns no longer sailing out,
the player stopped handing over free points and the ladder had to earn them
back. The opening got SOFTER at the same time — 0.72 where the old first
opponent was 0.85.

THE UMPIRE WAS MUTED IN THIS MODE and nobody noticed. aside() refused during
any interlude; the match runs on a permanent one. So the single mode that is
literally a tennis match he is sitting over was the one mode he never spoke
in. Same bug shape as sendBoy, same fix. Six dry lines via say -v Daniel: two
on taking a limb, two on your blood, one when there is nearly none left, one
on felling the thing. asideEvery still gates him.

...and sendBoy had the identical bug, so the mop boy now comes out between
games to the FAR court, where one body has been bleeding through four wound
stages. Between games only, never mid-point.

The line judge took two rolls: the first cut to 6 figures in its last row and
_zsheet.py refused it rather than mis-slicing. That refusal is the feature.

59 tests green. Verified in the browser: SHAMBLER on court in its own body.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:45:31 +02:00
wuwiandClaude Opus 5 c1f7268a04 The flick stops losing the point, and the corpse falls and serves
THE FLICK. "when returning a ball with a spin it seems too sensitive, 8/10
times the ball lands out." Arithmetic, not feel. The singles line is 4.115 m;
an aimed shot targeted a flat ax*3.3 and THEN added err() on top — up to
+-2.4 m at quality 0 — so a flicked return landed as wide as 5.7 m, and the
only clamp in the path sat at +-5, outside the court. An UNAIMED return
targets 0 + the same scatter and is therefore always in: aiming was the thing
losing the point, which is backwards for the mode's one act of skill.

TUNE.flickReach scales the aim width by the same quality the scatter shrinks
with, so the two can never sum past the line — worst case 1.49+2.4 = 3.89, and
a perfectly timed flick still gets the full 3.3 with nothing left to scatter.
The test asserts that sum across the whole quality range rather than sampling
rallies. Not named `aimReach`: that key already exists further down TUNE (the
slingshot's 22 m aim ray) and the duplicate silently won until a test caught it.

The placement ring carries `q` now and is the flick's receipt — tight and hot
when your timing earned the full width, wide and dim when you barely got a
racket on it. Without it the rule is invisible: the shot just goes less wide.

THE FALL (handoff item 4). A point that takes a limb knocks the body down and
it drags itself up short of it. The three collapse frames wear the stage it
STILL WAS and the rise wears the stage it has BECOME — that order is the whole
beat, and it is what turns a popping wound stage into something the point did.
Own clock beside the horde's warning, for the reason this file keeps relearning
(a beat that moves the phase under a latch strands it); the 'over' beat it sets
outlives the fall so it cannot expire into setupPoint() mid-collapse. Only
fires when the stage actually moved — a quarter off a full pool is 0.75, so
point one has no limb to explain and a second of collapse for nothing is a beat
the player waits out.

THE OPPONENT SERVES (handoff item 7), and it was mostly switched off rather
than missing. setupPoint already placed a non-player server behind its own
baseline and armed aiServeT; update() already tossed and struck for O. Both
were gated on `G.mode === 'tennis'` and the match runs in splatter under a
permanent interlude — one condition. Genuinely new: service alternates at each
game boundary (the match calls advancePoint directly and the flip lives in
tennis's pointTo, which it bypasses — the same shape as the G.serveNum bug),
the zombie takes O's mark so picture and ballistics agree on where it stands,
and the serve poses are named onto it because O is invisible here.

racketStage() moves to config: drawZombie and the fall must not answer the
same question twice, which is how the boss's skins and its blood drifted apart.

Docs: PROJECT.md gains THE ZOMBIE FAMILY PIPELINE — the measured API contract
(4K is a parameter and the default is 1K; n is 1 so "return both" is not a
thing; aspect is an enum so pad to it), why magenta stays despite green being
the documented key, and the two things _zsheet.py must do globally.

Verified in the browser: brute in the match, z_brfall2 -> z_brw1rise on a
wounding point, and bounce -> toss -> serve off the opponent's own baseline.
58 tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:08:02 +02:00
wuwiandClaude Opus 5 c89d4a3ff9 Three modes, picked up front — and CLASSIC TENNIS is one of them
The setup screen picks WHAT before WHO and WHERE. Three rows out of a table,
in the court row's own markup:

CLASSIC TENNIS is not the opener with the turn switched off and left there —
it is the mode the other two are built on, so it gets real match scoring: six
games with a two-game lead is a set, best of TUNE.classicSets is the match,
the board shows the sets column (hidden until now outside splatter) and the
bar under it says which set this is. The CPU stops rubber-banding and flubs
less: aiFlubLoss exists to get a new player PAST the opener, and a mode whose
whole promise is a real match cannot be won by an opponent that falls apart
the moment he goes ahead. No tiebreak — 6-6 plays on to 8-6. Marked ponytail.

That needed a second ENDING, the one with no corpse in it: G.done, the same
summary and gated tap as the death screen, its own words (GAME, SET, MATCH. /
BEATEN.) and the man still standing. The three endings now share one block
rather than three copies of it.

Caught by the first classic test: stats banked only the FIRST classic run of a
session. `banked` was cleared by the next KILL, and a classic match has no
kills in it at all — every later match was swallowed by the double-bank guard.
beginRun() is the honest reset and restartRun calls it.

ZOMBIE TENNIS from the menu is one line; the back room keeps its door.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 09:27:49 +02:00
wuwiandClaude Opus 5 66fdad3d74 A match run is a different kind of run, and stops polluting the gore stats
The two modes share no currency: gore counts kills, multi-kills and weapons;
zombie tennis counts opponents felled and has exactly one kill per opponent,
always credited to 'bomb' (matchGameWon's explode). Every one of those was
landing in splatterRuns as a gore run, on the roster as THE CLUB PRO, and on
the KILLS world board — where a five-body match would post a row next to a
300-kill massacre and mean something else entirely by it.

One character on the record fixes all of it: `m`, absent meaning gore (which
is what every existing record is, so no migration). read() takes a mode and
so does endRun, which now compares you only against your own mode and refuses
a name prompt from a match.

The record's two headline slots carry each MODE's two headline numbers — gore
g=games s=sets, match g=felled s=games — so `g` stays the thing everything
ranks on and the match best is derived from the record instead of living in a
second key that drifts. That retires the matchBest key from the last commit.

Found on the way, pre-existing and visible: endRun returned `{...best, kills,
games}` and the later key won, so G.best.games was the run that had just died.
The gore death screen has been printing "BEST <this run> GAMES · <the record>
SETS". The return now carries the best and nothing else.

And stats.check() — the module's own fixture check — was exported and called
by nothing. Wired into the suite, with the mode split added to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 08:58:04 +02:00
wuwiandClaude Opus 5 8011186cda The blood you have left, the bodies behind you, and a face for staging
Three things, all in service of the match reading as its own mode:

BLOOD, COUNTED. The vignette says "worse" and never says "how many". One pip
per hit left, top-right beside the board, in the panel language the game/sets
bar already uses; the last two breathe. Pips rather than a bar on purpose —
the pool is 4 or 8, and a smooth readout of a small integer is the exact
ambiguity being fixed.

OPPONENTS FELLED IS THE MEASURE (handoff slice 1). Blood-only scoring means a
lost game costs nothing but the blood already spent, so games survived is not
the achievement. The in-play bar prints OPPONENT n · m FELLED instead of the
game/sets line, the death screen says BLED OUT / FELLED n OPPONENTS · m GAMES,
and it keeps its own matchBest — drillBest's pattern. The kills-to-next-point
pips are hidden in a match: nothing there is killed for points, and they were
colliding with the new line anyway.

STAGING WEARS A DIFFERENT ICON. Both builds live on the same phone and two
identical bloody tennis balls is a coin toss. Driven by HOSTNAME, not by the
branch — the block is identical on staging and main, so it never conflicts on
a merge and never has to be remembered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 08:53:02 +02:00
wuwiandClaude Opus 5 ac588ec6a7 The longer racket
Third recorded session. The previous fixes did their job and exposed the last
gap: he ran onto every ball (2.9-3.9m per point, was 0.2-0.5), the ball
reached him (died at z 22.5-24, was 18-20), he swung every single time — and
the closest approach clustered at a sampled 1.3-1.6m, centimetres outside the
1.3m post-bounce reach, point after point. The assist delivers you NEAR the
ball but not inside a phone-tuned contact radius, and that also explains "not
really better or I played worse": every point suddenly looked makeable and
still died.

matchReach 1.35 widens the contact test in the match only — the gore modes'
"tight contact radius: the tap must be honest" rule stands untouched. A rally
mode lives on contact feeling fair.

Recorded for future sessions: a 20Hz sampler overstates closest approach by
up to ~0.5m on a 20 m/s ball. The recorder samples at 60Hz now.

49 tests, 12 seeds clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 23:40:08 +02:00
wuwiandClaude Opus 5 f37fb93875 The short ball gets chased, intercept gets legs, the vignette gets corners
Second recorded session: 5 wins and a deuce war to 5-6 — the mode is a
contest — but all seven losses shared one signature: the ball died in court
1.4-2m short of him while he ran 0.2-0.5m. Three defects:

The pace cut created dying rollers. Slow returns bounce and die at z 18-20,
outside intercept's near zone, so nothing chased them, and the rolled-dead
rule hands the point to the zombie. They also arrive at his feet below legal
contact height — the "ball arrives right at the player" feel. intercept keeps
an anyHit candidate now: the earliest reachable point on his half where the
ball is still at hitting height, so the assist runs forward onto the short
ball. Verified: he sprints z 23 -> 16.4 and meets it at gap ~0.

intercept computed reachability with autoAssist — 0 on any fine pointer — so
a desktop match's planner answered "home" every time. He had legs, his
destination was always the baseline. assistK() is shared by mover and
planner now.

And the vignette was a radial gradient in a portrait canvas: the circle
reaches the corners but hits the side edges at fraction zero — "red pulsing
from below" was the bottom edge being the only place it got close. Four
linear edge fades now, hugging the rectangle on any aspect.

48 tests, 12 seeds clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 23:36:15 +02:00
wuwiandClaude Opus 5 3a789eb2e9 The flight recorder finds the statue with a racket
"can you watch me play in the browser pane and see why I loose". A passive
20Hz recorder sampled his real play: seven losses, and in every single one he
SWUNG (his timing was fine) while the ball died 2-3m to his side and his own
x never moved all game. He had no legs: autoAssist keys off pointer:coarse,
the pane reports a fine pointer, so auto-run was 0 — the exact trap the
verification recipe has warned about for weeks, bitten in live play.

matchAssist 1.15: the match brings its own legs on every device. It is a
tap-timing game; footwork is the game's job. Held keys still override. Pinned
by a test that plays with autoAssist 0 and asserts he runs.

The vignette curve is sqrt(damage): the match pool is 8, so hit one was a 10%
alpha nobody can see. sqrt front-loads it — first blood reads at ~0.3.

This also explains three rounds of disagreement between his reports and my
measurements: my autopilot always had legs (it set autoAssist by hand), his
pane play never did.

47 tests, 12 seeds clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 23:28:47 +02:00
wuwiandClaude Opus 5 fdbadce94d Pace was the missing dial, and the reachability test finally exists
"there are still balls which are basically not reachable with auto run..
please recheck" — and, asked twice: "did you write tests for the last issues
we encountered?" The straight answer: no. The unreachable-balls issue only
ever had config assertions, which is why it came back twice.

Pace is the ladder's fourth dial. Width alone never fixed reachability
because the returns left at ~27 m/s, crossing to the baseline in ~0.6s — less
than auto-run needs from the far corner. matchOppPace 0.78 puts opponent one
at ~20 m/s (measured live: twelve returns, 20.0-20.7), a quarter more flight
time, and matchOppPacePer hands it back per rung.

The vignette deepens: dmg x 0.8, cap 0.85, and the clean centre shrinks with
damage instead of only the rim darkening.

And the reachability regression the issue always deserved: a headless 90s
autopilot soak asserting at least 10 returns sampled and at most 15% that
never came within volley reach. Sabotage-verified. Its honest limit is
recorded in PROJECT.md: the autopilot attends perfectly and taps
frame-precisely, so it out-covers a human — it is a floor against
catastrophic regressions, not a proxy for the thumb. Oliver's playtest stays
the real gate.

46 tests, 15 seeds clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 23:18:33 +02:00
wuwiandClaude Opus 5 8ccf0a8fad The flick shows itself, the width is a dial, the blood stays on
Three asks. The placement ring: an aimed rally hit stamps a gold ring that
fades where the shot is going — flick, watch it bite the corner, learn the
cause. Not stamped for unaimed hits (marking scatter would teach noise), gone
in 0.8s (a permanent ring reads as a target rather than feedback).

The opponent's width is the ladder's third dial. Its returns scatter off
aimError x (1 - quality), which painted lines from game one. matchWideBase
2.1 keeps opponent one inside what an auto-run player can cover — measured,
25 returns all inside +-2.2m — and matchWidePer earns 0.4m per rung, so
line-painting is the late ladder. Speed, reliability, width: three dials.

The blood vignette replaces the temporary border. The old wash flashed 0.3s
and vanished, so between hits the screen said you were fine. A radial
vignette reads G.hp directly now — its depth is your damage fraction,
permanent until healed, hit pulse riding on top. Global: the hp channel is
global. Same no-HP-bar philosophy one layer out; at 1 hp the frame is
properly blood-rimmed and the court centre stays legible.

44 tests, 12 seeds clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 23:09:33 +02:00
wuwiandClaude Opus 5 d6df582cd4 The match walks back to centre — "unreachable" was the player, not the CPU
"every second ball is basically unreachable by an auto-run player.. are there
some hidden gestures I don't know about or is it just a very strong cpu?"

Neither. The match inherited gore-splatter's depth-only recovery — the
sidestep-protection rule — by being G.mode 'splatter'. One wide return pulled
you off centre and you stayed there, so the next ball to the opposite corner
was lost before it was struck. A player who never walks back experiences
every rally as unreachable, and it reads as CPU strength because the balls
keep landing where he isn't.

The match recovers to centre now, like tennis: real footwork, and in a rally
there is no artillery lane to protect. Gore splatter keeps depth-only — the
sidestep it protects has no role inside a rally. matchSpeedBase 1.4 -> 1.25:
it was calibrated against a player who couldn't move.

The full attacking vocabulary is recorded in PROJECT.md — nothing else is
hidden. The placement flick (aim during the swing) is taught nowhere on
touch; flagged as a ghost-hand lesson if the mode ships.

42 tests, 12 seeds clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 23:02:14 +02:00
wuwiandClaude Opus 5 24be613c61 The toss is the server's ball — the real death loop
"still when I lost a single point and try to serve again every further tap
does hurt me more until I am dead". Reproduced tap-for-tap this time, and the
serveNum fix was a red herring — a real bug, but not this one.

tossBall never stamped ball.lastHit. After any rally the zombie touched last,
the freshly tossed ball still said lastHit 'O' — and the previous pass moved
the server behind the baseline, past the passing-shot line. So the moment the
toss went up, the game saw an opponent ball beyond your baseline: "its
passing shot ends the duel", point to them, blood. Every tap = a toss = an
instant lost point, faster the more he panicked. Two fixes composed into a
death loop; the stale field was harmless while serving happened inside the
court.

One stamp in tossBall — ball.lastHit = sv.side — fixes every caller. The duel
and the gore mode carried the same latent bug for anyone tossing deep behind
the line after a racket-zombie touch.

Verified with the exact field shape: lose a rally the zombie touched last,
then eight badly-timed panic taps — one honestly lost point, zero
toss-bleeds. Before the fix every single toss bled.

41 tests, 15 seeds clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:54:13 +02:00
wuwiandClaude Opus 5 f67411e330 The opponent shows up, and the death loop dies
"still very weak and slow opponent. it also spawns at places where it is
impossible to react to my serve. so i win like always. in the cases i dont
win there is a bug. when trying to serve after i got hurt i get hurt again
and again until i die".

The death loop was G.serveNum. Tennis's pointTo is the only thing that ever
reset it, and the match bypasses pointTo entirely — so the first fault of the
match armed "second serve" permanently, and every missed serve after it was a
double fault, a point, and blood, repeating to death. Exactly the spiral a
rattled player serves into. matchReady(), the match's own point boundary,
resets it now; a regression pins it.

The receiver starts every point on its mark: matchReady drops it onto the
exact receiver position the tennis branch computes for O, deuce/ad mirrored,
instead of a random spawn x or wherever the last rally left it. An opponent
that cannot reach the serve is not weak, it is absent.

And the base opponent is a player now: matchSpeedBase 1.4 (the duel's 2.8m/s
lumber was tuned for one sudden-death point), matchFlub 0.08 (0.2 gives away
a fifth of all returns). Measured with the same autopilot that previously won
seven straight games: it now loses the match. Possibly too hot — the two
dials are named, Oliver's thumb decides.

40 tests, 15 seeds clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:41:10 +02:00
wuwiandClaude Opus 5 ea6e50f8f8 The match plays fair: deeper pool, no regicide, serves from the line
"the player is too weak in this mode. also the killing by smashing occurs too
often. i cant really control it. maybe obsolete? also the serving after the
first serve does not happen from behind the groundline".

matchPlayerHp 8 — the match brings its own pool. The gore mode's 4 was an
inheritance, not a decision, and a real game has rallies you deserve to lose
without the run ending.

Regicide is out of the match (kept in the duel, where any-weapon-counts is
the point of sudden death). In a full game every hard serve that happened to
meet the body was a free kill nobody aimed. A match ball never damages the
opponent — it passes through like a soft ball and racketAI plays it — so the
wound stages are exactly the scoreboard. That also made hp-per-opponent
meaningless, so the ladder escalates in racketAI instead: +12% speed per body
felled, whiff chance x0.82 per rung.

And serving resets to behind the baseline with real deuce/ad mirroring:
setupPoint's "serve from wherever you're standing" branch now excludes the
match, which falls through to the tennis positioning. Verified live: second
serve from z 24.3, x mirrored to the ad side, opponent unwounded by anything
but the score.

38 tests, 15 seeds clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:31:57 +02:00
wuwiandClaude Opus 5 0620e97d6c Zombie tennis: one game per opponent, blood only
Triage failed the only test that matters — "i think the normal player does
not grasp this" — and the lesson across four drill designs is that every
invented rule needs teaching, while the rules the scene already implies need
none. So the new mode is the game's own oldest rule: tennis.

MATCH TEST in the back room (#match). One game per opponent, real 15/30/40
scoring. Built on top of the duel machinery: G.match runs a permanent
interlude, which is already what makes serving tennis-like, hides the rack,
quiets the spawn director and wakes racketAI. The one change is in
resolveInterlude — a settled point feeds matchPoint instead of ending
anything. About 90 lines for a whole mode, because the deepest system in the
repo was already built.

The score is the body: a quarter of its pool per point won, floored at 1, so
a love game walks the racket zombie through every wound stage and the game
point fells it. Its points draw your blood — hp--, eaten at zero, matchHeal 2
per game won is how you last. Regicide is inherited: kill the body mid-game
and the game banks by hand.

The timing is drawn, per Oliver's ask: an approach ring around the incoming
ball that kisses a fixed inner ring exactly at the sweet contact distance
(gold inside the band), and an apex band beside the toss spanning the same
0.12m keyPressed scores as perfect, with a tick riding the live ball. Both
cues use the mechanics' own numbers, so the picture cannot lie.

Verified by autopilot: 7 games in 90 simulated seconds, opponents 1..8, pools
4/6/8/10, wounds tracking points exactly.

36 tests, 15 seeds clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:19:37 +02:00
wuwiandClaude Opus 5 be191dba82 The marker stops lying, and the rule announces itself
"the marker flips all the time even during a ball being midair.. i am not
sure that a player would get the mechanic intuitively let alone without the
marker".

The flip had a specific cause and it was a design bug, not jitter. With lanes
held and speeds constant, arrival differences never change while bodies walk
— the queue only reorders on a kill or an arrival. The marker flipped because
it showed only q[0] while the rule accepts any near-tie within the slack:
every kill made it jump between tied bodies, so the marker was stricter than
the rule it was explaining. drillLegal() marks the whole slack set now,
bright head and dim ties, which is stable by construction. Measured: zero
head-changes across 12s of walking with no kills.

Learnability, two answers. The rule gets one modal, once per run, on the
mode's own calm court — CLEARANCE DRILL / DROP WHOEVER WOULD REACH YOU FIRST
/ FAST BEATS CLOSE / STREAKS PAY — because "most urgent first" is not
guessable from a chevron alone. And the markers are training wheels that take
themselves off: hidden at streak >= 8 (the streak is the evidence you can
read the court), back on any fault. The teacher steps in exactly when you
demonstrably need it.

31 tests, 15 seeds clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:44:58 +02:00
wuwiandClaude Opus 5 6a16e77480 Triage: always kill the body that would reach you first
The sequence drill tested nothing — the strip was always visible (no memory),
the batch was slow (no urgency), the aim was easy (no execution). A checklist
with gore, and Oliver said so: "not interesting enough to keep me playing".

Settled in discussion, he picked triage + streak. One rule: always kill the
body that would reach you first. Nothing is called, nothing shown — the court
is the puzzle. arrival = distance / speed down a held lane, and the roster's
speed spread means the answer is not the closest body: a runner at the far
baseline beats a roller at the service line. Court sense — and it trains the
exact target-priority read the gore mode rewards.

Wounds are free: only the kill is judged, so pre-softening a heavy while it
is third in line is legal and eventually necessary. The slack (0.9s) makes
near-ties legal both ways, or the rule is a coin flip. The streak is both
punishment and payout: x1..x5, a misread shows OUT OF TURN and resets it, and
nothing else happens — the queue keeps walking. Caught is the real setback.
Best score banks to localStorage; the world board can carry it if it ships.

drillMark draws a chevron over the queue head for playtest calibration. It
answers the exact question the mode asks, so it must be off in anything
shipped.

Deleted: the alphabet, the batch, the strip, the eaters. Shamblers are
first-class stream members with pts 20; a roller is 100.

30 tests, rewritten around arithmetic riggings (known speeds and distances)
instead of luck; 20 seeds clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:32:17 +02:00
wuwiandClaude Opus 5 57e96d629e The finishing shot is spent, and the wound skins come back
"one-shot is bad - way too easy - also we're losing all the nice gore
animation sprites" and "I noticed the last kill of a game can carry to the
spawn of a new zombie of the next round".

drillOneShot is off. The second half of that note is the part worth
remembering: a 1 hp body never passes through a wound stage, so every skin in
ZWOUND — arm off, ribs out, still walking — is skipped and the roster's best
art never renders at all. Kept as a switch, never a default.

The carry-over was a real unfairness, not a curiosity. A ball keeps pace
through a kill and travels UP-COURT, which is exactly where the next batch is
born — so the shot that finished your round flew on into bodies that did not
exist when you took it, and faulted you on a round you had not been shown
yet. The shot that ends a round is spent now, and drillCaught does the same
since it also refills the court up-court. Pierce within a batch is untouched:
that is honest occlusion you could have read, this was not.

Verified with real aimed serves through a whole round: round banks, ball goes
inactive, the next batch walks on with 0 faults and a clean sequence, and the
gang carries roster HP again (brute 5, ball boy 3).

33 tests, 15 seeds clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:43:26 +02:00
wuwiandClaude Opus 5 0f524a08c9 Two testing switches: one-shot bodies, and the eaters off
"please make the zombie killable with a single hit for testing" and "remove
the fake zombies for now".

drillOneShot drops every body on a drill court to 1 hp, applied at spawn to
the batch and the eaters alike. Worth knowing what it removes: the roster's
HP is what makes the called ORDER matter — a roller is normally seven serves,
so being told to take it third means starting on it first. At one hit that
planning layer is gone and the sequence is pure recall. Flagged in PROJECT.md
so it gets decided on feel rather than left on by drift.

drillEaterMax 3 -> 0 switches the plain shamblers off. The mechanism is
untouched — spawnEater, the wide entry away from the gang, the never-a-fault
rule — so one number brings them back.

The eater test now asserts whichever contract the knob describes (at 0:
nothing but the puzzle ever appears; above 0: they arrive, capped, always
plain shamblers) instead of being deleted and rotting. And the never-a-fault
rule gets its own test with a hand-placed body, so the rule the eaters depend
on stays covered while they are switched off.

31 tests, 15 seeds clean. Verified live: brute/steward/roller all at 1 hp
where the roster says 5/5/7, zero strays over 40s, one aimed serve puts down
the roller.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:37:01 +02:00
wuwiandClaude Opus 5 d34683bc68 A flaky gate is worse than no gate: seed the suite
The pipeline caught two flaky tests of mine that sixty local runs had not.
Both were bad tests rather than bad code, and both were mine from this
session:

- the eater test counted SURVIVORS, not arrivals. An eater crosses the whole
  court in ~24s and despawns, so a snapshot at 40s can legitimately show an
  empty court — that read as "the eaters never came". It tracks arrivals over
  the window now, and asserts the cap as a running peak.
- the auto-aim test never actually aimed at the body it claimed to: fy was
  pinned at -0.95 and fx was a scaled x offset, so the ray leaned up-court and
  the called body could genuinely be the best-aligned thing in the cone. That
  is the magnet working. It now normalises a vector straight at the wrong
  body, picks the one furthest in x from the called one so the two are
  separable, and asserts the magnet takes what you pointed at.

Measured 8 failures in 40 runs before, 0 in 60 after.

And the durable fix: Math.random is seeded at the top of test.js. This suite
is a Dockerfile build stage, so a red run blocks the deploy, which makes a
1-in-5 flake worse than no gate — it trains you to re-run it. The game leans
on randomness everywhere, so the stream is made reproducible rather than the
randomness stripped out. SEED=1234 node test.js re-rolls it; 25 seeds pass.
The cost is noted in PROJECT.md: under one seed a guarded test can silently
take its early-out forever, so I checked all four guards still run their
bodies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:29:34 +02:00
wuwiandClaude Opus 5 5b2f1d55ce The roster is the alphabet: the sequence is the zombies themselves
Oliver's insight, and it retires a whole subsystem: "we don't need colors at
all! we already have different zombie types.. so the sequence is the zombies
themselves".

The bib and then the ground disc were both inventions bolted on to give a
body an identity it already had. A brute does not look like a runner, the
game is built on reading those silhouettes at speed, and the strip can show
the body's own walk frame instead of a swatch — no legend, no mapping, no new
art ever. It also deletes the accessibility problem outright rather than
deferring it, because a silhouette is not a hue. DRILL_COLS, drawColourRing,
drawBib, PIP_FACES, blip(), drillRingR and drillTones are all gone.

Two rules keep a type unambiguous: the plain shambler is never in the
alphabet (it is what the eaters are, so "not the puzzle" needs no marking),
and one of each type is on court at a time.

The new failure mode this created is instructive. Types move at different
speeds and drill bodies hold their lane, so one in a lane you are not in
walks the whole court and off the back untouched — stranding the sequence
asking for a body that no longer existed, i.e. the un-winnable state the mode
exists to prevent, reintroduced by a change that looked cosmetic. A batch
body crossing your baseline counts as caught now ("GOT PAST YOU"), which is
also just better design, and the speed spread is narrowed to 1.35..0.70 so a
batch stays coherent. Their real difference is HP and that is untouched: a
runner is one shot, a roller is seven.

Verified: 14400 frames of simulation, zero frames where the sequence named a
body that was not on court.

29 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:24:58 +02:00
wuwiandClaude Opus 5 abd6d4f789 Every colour you are asked for is already on the far side
"make the game that it is actually winable meaning that all required colors
are visible on the far side before when a color combination is shown.. for
now no colors that are not in the current combination but occasional zombies
which don't have a color and are converging to the player trying to eat him".

Two rules define the file now. Every colour on court is IN the current
combination — no coloured decoys, so the strip can never name a body that is
not there. And a combination is only shown once its bodies are all on the far
side, which is free because the batch is BORN there: it is a property of the
construction rather than a check that could be wrong.

The pressure moved to the eaters: plain unbibbed shamblers on their own
clock, deliberately not flagged `drill` so they keep the gore mode's homing
and converge on you while the bibbed batch walks its lanes. Killing one is
never a fault; letting one reach you is the ordinary caught. The puzzle plays
fair and the danger arrives separately, which is a better trade than a puzzle
that cheats.

The batch is the round: kill the last of it and the next walks in on the same
frame with its combination already called.

One real bug, caught by the new test: killZombie resolved the drill BEFORE
splicing the body, so a combination drawn on the spot could name the corpse —
a round asking for a colour that is not on court, i.e. exactly the unwinnable
state this pass exists to prevent. Splice first, resolve second.

28 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:29:04 +02:00
wuwiandClaude Opus 5 1d49c4343b 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>
2026-08-11 19:06:48 +02:00
wuwiandClaude Opus 5 435a470ec7 The umpire is one man with one microphone, and voiceSeq forgot it
"the drill mode immediately goes into some horrible audio loop" — on a phone,
never on desktop, and that is the tell. Three of the four defects can only
happen on a phone, and every one of them is a rule voice() had followed for
months that my new function quietly broke.

- A SUSPENDED AudioContext HAS A FROZEN CLOCK. Scheduling four clips at
  currentTime +0/+0.9/+1.7/+2.4 while the context is suspended puts all of
  them in the past by the time it starts, so they fire on one instant through
  a 1.3s reverb tail. Resume first, re-read the clock after the decode.
- A second call layered instead of replacing. voiceSeq SET voiceUntil and
  never checked it. It stops the outstanding list now.
- ...and cancelling a list has to give back the time it booked, or the
  replacement queues behind a list that will never play. Measured at 6.6s of
  silence before the call the player is waiting on.
- The amplifier was in drill.js: the empty-court net redealt on the spot,
  making the repair rate the frame rate — and every deal calls the umpire. It
  goes through the ordinary between-rounds beat now.

Verified by tagging each decoded buffer with its source file and counting
start() calls: one drill_call, three colours, spaced, zero overlapping voice
lines. The earlier "clean" reading counted fetches, which proves nothing —
VOICE[] caches, so a repeat play never fetches again.

27 tests; the new one empties the court every frame for six seconds and
asserts at most one umpire call per beat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:29:11 +02:00
wuwiandClaude Opus 5 ef8a413031 You aim it yourself now, and no two rounds are dealt alike
Oliver played it: "the auto aim just mean no skills involved at all. this
should just go away.. there is no penalty at the moment.. make some
variations of the position on the court and the color".

The first two notes are one finding. With the shot locked to the called body
it was unmissable, so the fault could never fire — there was no penalty
because there was no way to earn one. Both snaps are deleted. What replaced
them is one line: aimTie is 0 during a drill. That is not auto-aim, it is
removing the guess — the ordinary magnet lets a nearer body inside the same
cone steal a shot aimed past it, which is right for a crowd you are mowing
down and exactly wrong where hitting the wrong body IS the failure. An
unaimed tap now lands on grass.

And the variation:
- six colours (purple, white) with the court dealt three to five of them, so
  which colours turn up and how many decoys there are are live questions. At
  four out of four every round dealt every colour and only the order moved.
- PLACEMENTS: fan, cluster, column, stagger, wing — weighted, mirrored per
  round, lanes shuffled against the bib order. column stacks bodies in one
  lane, and the ball hits the first thing it touches, so feet matter.
- pips are dice faces now, shared by bib and HUD chip. A row of five dots has
  to be counted; a die is recognised.

Measured over 30 deals: all five shapes, counts even, all six colours, and
the first target screened by another body in ~10% of rounds. Verified end to
end in the browser that FAULT and DOUBLE FAULT both fire.

26 tests, including a named regression for the auto-aim staying gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:16:25 +02:00
wuwiandClaude Opus 5 22df96cc1d 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>
2026-08-11 18:01:33 +02:00
wuwiandClaude Opus 5 c659b6882f A burst fires for its whole window, and the countdown is the tile itself
The gun, beacon, flamer and rocket fell silent the moment they cleared the court:
firing and steering were both gated on phase 'rally', and every beat raised by the
kill that empties the court (a point, a won game, the mop-up) parks the phase in
'over'. A burst is its own control mode for its whole len, like a horde — its own
clock is the only thing that ends it. Not fixed by forcing the phase back to rally:
a parked 'over' beat that never expires is how pendingHorde gets stranded.

The countdown moved off the tile's bottom edge (the rounded frame, and the bottom of
the screen) into the tile: the armed slot's well drains as a gold wash, clipped to
its own rounded path, under the icon and labels.

16 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:49:02 +02:00
wuwiandClaude Opus 5 5ea84f8511 Nothing arrives unannounced: the horde gets its breather, the boss gets a name
THEY KEEP COMING is a 2.4s 'over' beat again (warnHorde in beats.js) instead of a
flash raised on the same frame the wave lands, and the chapter boss walks on behind
an overlay — his name in red over ENTERED THE COURT — held for 3s by the new
self-dismissing modal clock (m.hold) rather than announced on a routine-set card.

startHorde returns a bool: if the court has been taken when the warning expires the
pendingHorde latch is re-armed rather than the set losing the horde it paid for. The
warning ticks on its own clock, not on the 'over' beat, so a beat that moves the
phase underneath it can't strand it (the mgT lock, again).

Three tests, 15 total.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:38:29 +02:00
wuwiandClaude Fable 5 1f636530b9 The code moves into src/: index, server and the test harness keep the root
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 13:45:23 +02:00
wuwiandClaude Fable 5 c122745390 One file owns the court: beats.js, PROJECT.md (né HANDOFF), and tests gate the build
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 13:23:27 +02:00
wuwiandClaude Fable 5 05930f0877 One mode owns the court: the duel can no longer start inside a horde, and the game has tests
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 12:55:37 +02:00