Oliver walked the sprite bench and reported the same two faults on every family:
the healthy walk loses its head, and the wounded walk's feet do not move. Both
were in the ONE FILE every zombie tennis character is drawn from — and the family
pipeline had copied it faithfully, which is the pipeline working.
WHERE IT CAME FROM. That sheet was not composed from the sprites. It was a
GENERATED RE-DRAW of the club pro, made in nine separate 4K calls, one per row
band, then stacked (gen4k.py, in a session scratchpad, untracked and now gone).
Nine calls returned nine scales, and call 1 came back with the shuffle frame
decapitated. Measured against the sprite it was drawn from, that one figure
scaled 2.341 wide and 1.996 tall where all thirty others were proportional to
three decimals: 33 px missing off the top, width untouched. The local input to
that call was exact at 2.000x. The sheet lost the head, not the sprite.
THE FIX IS TO STOP PUTTING THE REFERENCE THROUGH THE MODEL. _famref.py composes
it from the 31 z_r* frames on disk — one scale, applied once, NEAREST — and costs
no tokens. That kills the head and the five scales together, and it explains two
things in _zsheet.py that read like fudges: the fall1 re-anchor and the walk-frame
levelling exist only to undo per-band scale drift. The levelling is also what
turned a headless z_sh2 into a headless AND 13% oversized one, by reading the
missing head as a height error and scaling up to compensate.
THE GATE WAS MISSING ON THE INPUT. _zsheet.py checks what comes BACK from the
model — band count, figure count, stacked bodies, edges, palette, heights — and
nothing had ever checked the sheet handed TO it. _famref.check() re-reads the file
it just wrote with _zsheet's own detection and refuses one where any figure's
width and height scales disagree by 2%, or where a stride pair is frozen.
THE THIRD DEFECT NEEDED NEW ART. z_rw1b had the same legs as z_rw1 (15.1% against
the healthy pair's 51.1%), so the club pro has slid through both wound stages
since the day he shipped, and every family inherited it. _stride.py draws the
missing twin, and what made it work after words had failed for as long as they
were tried is SHOWING the edit: image 2 is the healthy pair side by side, the same
character performing exactly the change being asked for. The headless twin then
took the ARM-OFF pair as its example — a wounded body is a closer picture of the
edit, the same chaining the wound ladder already uses. Its gate measures three
things and prints all three: legs moved, body above the hips did not, racket kept
its bed. The thresholds are CALIBRATED on pairs that already ship, not chosen —
my first guesses rejected z_e1/z_e2 and z_g1/z_g2, both accepted art. And a twin
FILLS the racket bed instead of keying it out: the first rolls came back with 959
px of enclosed transparency against their base's 0, which would have flickered the
racket face empty/filled once per step.
AFTER, on the installed sprites — leg difference within each stride pair, where
the wounded pairs used to sit between 4.8% and 19.7%:
r 51.1 / 68.1 / 36.7 ru 55.9 / 59.1 / 38.7
br 40.7 / 42.0 / 26.8 gk 41.1 / 44.6 / 30.6
sh 59.4 / 71.4 / 43.9 lj 38.4 / 45.5 / 27.4
bb 56.5 / 67.1 / 43.2 st 51.6 / 66.1 / 35.7
All seven families re-rolled off the rebuilt reference, every play row with its
head, and all 24 family/wound-stage states alternating a real pair — checked by
instrumenting drawImage on the bench, not by eye. br and lj sit lowest on the
headless pair and are worth a re-roll if they ever read as stiff; lj needed one,
its first roll came back frozen on all three pairs with the headless row 31 px
short.
AND THE REFERENCE IS TRACKED NOW. It lived in gitignored _refcheck/, so the thing
the entire pipeline rests on was unreproducible for as long as it existed. It is
assets/_pose_ref_family.png, next to _pose_ref_serve.png, and the scripts that
build and consume it are in the repo instead of a scratchpad.
Also in here: the SHAMBLE BOB, which was a 1 px lift on a wall clock applied to
every body every frame whether it was moving or not — a held pose bobbed forever
and a corpse bobbed while it lay on the ground. It only happens while walking now,
and it steps off ground covered, which is the same rule the strides follow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
A day went into sprite consistency and most of it was spent solving the problem
in the wrong place. Written down so the next session starts from the answer
instead of rediscovering it.
The boundary everything else follows:
the generator, GIVEN REFERENCE IMAGES -> identity, pose, composition
the pipeline, DETERMINISTICALLY -> exact colour
the gates -> layout accidents only
With the proof, because it is the part that is hard to believe until measured:
the brute's serve sheet came back with his build, his garments and his DARK
TONES pixel-identical to the reference and his big pale shirt two shades
brighter. The model was looking straight at the character. Every call
re-renders and re-rendering re-lights. No prompt fixes that.
And the rule underneath all of it — A REFERENCE BEATS A DESCRIPTION, at every
level. The racket zombie's sheet was made from the character as an IMAGE plus
the poses as IMAGES and landed first time; the brute's three sheets each
re-derived the character from the same paragraph and produced three different
brutes. Corollary worth having: every corrective sentence in a prompt is
compensating for a reference you did not supply. When a prompt grows a third
"do NOT" clause, add an image instead.
Also written down: correct-then-check, calibrate-before-you-gate (with the
numbers every threshold was calibrated on), the five gates and what each one
actually measures, six traps that each shipped something silently before they
were caught, and three things still open — including that `changed` passed a
headless brute that still had its head, because it knows something moved and
not what.
The brute is PARKED: out of ZFAMS, 31 frames on disk, nine batches in the JSON,
one line away from live. Its healthy eleven are good; its wound stages are not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Decided with Oliver 2026-08-12. Records why tennis is the friendly case (the
sync unit is the shot, alternation already enforced by ball.lastHit, each
client authoritative for its own contacts), what the codebase already brings
(deterministic sim, fixed dt, beats.js ownership contract), the zero-dep
WebSocket path (~120 lines of stdlib RFC6455 in server.py, friend-code
rooms), the honest cost (reconnect-and-resume is the real work), and that
step one is the GHOST MATCH over plain HTTP — half the netcode falls out of
it as a byproduct. Plus the hook: score-is-the-body on both players, losing
is zombification.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fourth recorded session, verdict "better": he beat opponent one — the first
game won in any recorded session — and reached opponent two, which took him
apart with gaps of 1.66-2.13m (60Hz, accurate) while he swung at closest
approach every time. The gradient is real; the step was a wall, because a
rung moves three dials at once. Per-rung increments softened: matchSpeedPer
.12->.08, matchWidePer .4->.25, matchOppPacePer .05->.035. Opponent one
untouched — the bases stay, only the slope flattens.
The contact chain is now verified end to end by recorded play: legs
(matchAssist) -> destination (assistK in intercept + the anyHit short-ball
chase) -> reach (matchReach). Each link was a separate defect found by a
separate recorded session.
49 tests, seeds clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
"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>
"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>
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>
"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>
"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>
"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>
"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>
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>
"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>
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>
"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>
"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>
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>
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>
"remove the mob boy - make the normal zombies rarer and not so close to the
color gang".
sendBoy refuses during a drill, guarded at the function rather than at the
call sites — same shape as its existing G.interlude guard, so every present
and future caller inherits it. He is one more body crossing the lanes you are
reading colours down, and unlike everything else out there he is neither the
puzzle nor the threat. Measured: 0 boy-frames in 90 seconds. Gore no longer
gets mopped in this mode; the existing 40/30 caps are what bound it.
The eaters are rarer — every 12s falling to 5s, capped at 3 on court. At the
old rate they were a second crowd rather than an interruption, and a threat
you break off to deal with only works if breaking off is occasional. 8
arrivals in 90s where the old numbers gave ~13.
And they enter wide of the colour gang: best-of-9 lane sampling against the
bibbed bodies, the same trick the batch uses. Narrowest clearance measured
across 8 spawns was 0.91 m. They still converge on you afterwards — that is
what they are for — this only decides where they walk in, so they stop
screening the bodies you are trying to read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"the colored zombies should also explode like they did before and make the
normal splash sound on destruction.. no bleeps please.. can we make the color
as a color circle the walk on? the sign they're holding is not nice. also
remove the ball slicing in this mode.. this leads to unwanted hits".
The silent deaths were my bug: the hook in killZombie did drillKill(zb) and
then RETURNED, and everything below that return is the death — decal, pile,
36-particle burst, splat, headshot crack. It resolves after the splice and
without returning now. Both halves are load-bearing and each was a bug in
turn: splice first or a freshly drawn combination names the corpse; don't
return, or the body simply vanishes.
The colour is a disc on the court the body walks on, drawn before the sprite.
The bib was a flat quad pinned to a walking sprite — UI stuck onto a
character, covering the art it sat on. On the ground it takes the court's own
perspective, measures 12.4px across at the spawn line against a chest patch's
few, and hides none of the sprite. A hard dark rim plus a bright inner line
is what keeps it from reading as another gore decal.
No slicing in the drill: a cut ball leaves the corpse and strikes whatever it
finds next, which in a mode where the wrong body is the failure is the game
choosing your target for you, badly.
And the mode has no sound of its own now, deliberately. The umpire reading
the list was vetoed, the arpeggio that replaced him was vetoed. blip() is
deleted. A kill bursts and splats like every other kill in this game; the
strip carries the list.
28 tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"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>
"what are the dice dots for?" -> "drop them", and "the dodge swipe should not
reset to the middle when firing".
The pips were colourblind redundancy so the mode did not rest on hue alone,
but they were noise to everyone who can separate the colours and they shrank
to nothing on a body at distance — exactly where the channel had to work.
Gone from the bib and the chip. Recorded in PROJECT.md as a known gap rather
than pretended away: if the mode ships, the answer is bibs that differ by
shape at sprite scale, not dots.
The dodge one was never a drill bug. The auto-assist recovered toward HOME.P
including its x, so firing walked you back to the centre line: measured, a
step out to 1.94 m was dragged to 0.77 m over one ball's flight — most of a
dodge, handed back for free, which is the welded-in-place complaint the
sidestep exists to answer arriving slowly instead of all at once. Splatter
recovers in DEPTH ONLY now and holds whatever lane you stepped to. Tennis
keeps the full recovery: recovering to centre is the real sport's footwork
and there is no sidestep there to protect.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The doc edit threw on its second assert last commit, so the code shipped
without its notebook entry. Also records the two ways a drill test lies to
you: one that never shoots gets caught and the sweep looks like a stalled
stream (use eatenR = 0), and zombies[i] is not the same body across frames
because the array shifts at the baseline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"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>
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>
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>
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>
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>