Oliver's idea, and it is the fix for the process as much as for the frames: give
the generator the real serve sequence as an IMAGE and let it copy the poses,
instead of describing a pose in prose and re-rolling until it lands. One
generation — a composite of his five photographed frames over two of our sprites
— returned a magenta row of the whole serve in the game's own character and kit.
The four stages are sliced straight out of it, anchored on the standing figure.
_fine.py gets --defringe: its two de-fringe passes each eat a one-pixel line, and
a racket frame IS a one-pixel line — that is what had been leaving the men holding
a grip and nothing else.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The racket kept changing hands because the art disagreed with itself: p_idle,
p_bounce and p_toss held it in his LEFT hand while p_smash and p_swing used his
right, so every new frame copied whichever reference it was handed. The idle, the
bounce pair, the toss and the four wind-up frames are all regenerated with the
side named — drawn from behind, his right hand is the right-hand side of the
image, and only the racket HEAD crosses behind his back.
What separates the four wind-up stages is how far the racket has fallen, and that
needed the racket head pinned to a body landmark per frame — above his head,
beside his ear, between his shoulder blades, at the back of his thighs. Described
as "further into it each time" it drew four near-identical poses twice running.
They are wired late in the pull, Oliver's call: frame 1 owns the first half and
the three coiled frames are packed into the top half.
assets/_derive.py is deleted. Fixing generated art by mangling it afterwards is
always worse than regenerating; three guards in _regen.py turn the bad rolls that
used to install silently into automatic re-rolls — a portrait reference canvas
(the generator answered with a 2x2 grid), a non-magenta background (a green sheet
keyed away four racket heads), and a mirrored frame.
The bounce pair is one drawing from one sheet now, differing only in the free
hand, so BOUNCE_IDLE moved to his left side and was re-measured off the sprite.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Oliver, off the file browser: "the racket changes the hand in 3 and 4". It did —
the generator drew frames 1-2 right-handed and 3-4 left-handed. Both mirrored into
line with the rest of the serve (a back-view figure mirrors for free), and the
batch prompt now names the side, since the generator will not hold a prop in one
hand across a sheet unless told to.
The convention the rest of the art already uses: racket on the LEFT-HAND SIDE OF
THE IMAGE, free arm on the RIGHT — the side the game drops the bounced ball and
tosses the served one. p_smash keeps the racket on the right: that is the frame
where it has come over the top to meet the ball it is hitting.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four new frames (p_pull1..4) turn the slingshot drag into the serve's wind-up:
racket above the head, behind the head, behind the back, fully coiled, picked off
pullFrac — the same 0..1 that sets the shot's power — so the gesture and the man
are one movement and the release snaps to the smash.
The two bounce frames are one drawing now. p_bounce2 is p_bounce with the free
arm sheared up 12px and nothing else touched, so the swap cannot flicker, and it
follows the ball instead of its rising phase; BOUNCE_IDLE was re-measured off the
sprite so the ball tops out under the palm. p_toss had the tossed ball drawn into
it — cut, canvas height kept, because the game scales him off img.height.
The runner: hip centres sat -2.0/+7.1/-6.0/-0.1 px off the sprite centre, a 13px
lateral jump at 12 frames a second, and Oliver's diagnosis was right that all four
frames leaned the same way and lifted the same leg. Frames 3 and 4 mirrored, all
four padded to one width with the hip on the centre line.
assets/_derive.py holds the three derivations, since "the same drawing with one
small difference" is the one thing the generator cannot draw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Oliver spotted it before testing: a whole-frame mirror swaps the DRAGGED POLE
into the other hand as well as the legs. True for the one wound stage that still
has an arm holding something, so that stage gets a leg-band mirror about the hip
centre instead — legs swap, pole and stump stay put. The other three stages have
no arms and no props, so a full mirror is right for them.
The honest cost: a leg mirror leaves both feet in the same two places, so that
one frame reads as a leg swap rather than a stride. Written up with the fix that
worked for the shamblers (a passing pose, feet together) and the measurement that
tells them apart.
HANDOFF gets the session: what changed, the two lessons that cost the most (the
prompt sets the art resolution; the reference sheet is what the generator
copies), the rule that anything which must stay identical between two frames
belongs in code, and the four known limitations ranked for whoever picks it up.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All four stages drawn from the healthy body in two sheets, so he keeps the same
bulk the whole way down instead of slimming as the wounds get worse — one arm and
his pole, both arms, ribs open, then headless.
The second frame of each stage is now a MIRROR of the first, not a generation.
Every attempt at drawing them moved the injury between the two feet — this is the
third fix for the same complaint — and a mirror physically cannot: the wound
stays on the same part of the body, and flipping a standing figure IS the stride
swap the B frame exists for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Oliver had the diagnosis exactly right: the raised racket is part of the sprite,
so the toss and smash frames were 29% racket by height — and the game scales a
sprite off its TOTAL height, which shrank the body to fit. Measured 71% body
against the standing frame's 98%. Both are scaled so the BODY lands at standing
size and the racket now extends above the frame instead of squeezing him into it.
The bounce frames are redrawn too: racket in the left hand, right hand empty and
reaching down to the side the game actually drops the ball on, and no ball drawn
into the sprite at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The generator drew the second frame of that wound stage as a MIRROR of the first,
so the missing arm and the dragged pole swapped sides on every step — which is
the "wound on the left arm on one foot but not the other" Oliver saw, and it was
on the sheet, not in the frame assignment. Flipping the frame back puts the
injury where it belongs, and a horizontal flip IS the leg swap a second stride
frame wants, so the walk still reads.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The playtest list, art side. The generator had been copying the very frames I
wanted changed, so:
- The player is drawn FROM BEHIND again in every pose — standing, bouncing,
serving, swinging. He had turned to face the camera in the redraw, which is
also why he "looked at you" in different poses.
- The serve frames are back at full size. That batch had been anchored on the
toss frame, so the whole serve set came out about a fifth too small.
- The runner CHARGES AT YOU down the court instead of sprinting sideways across
it, which is what he actually does in the game.
- The brute's wound stages are drawn a pair at a time from their own A frame, so
the injury stops moving between the two feet.
- The bounce frames had a ball painted into them while the game draws a live one
on top — he was bouncing two. The painted one is gone, and the live one now
falls on the side the sprite actually reaches for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reference sheet is what it imitates, so building that sheet out of the very
sprites I was trying to change is why the front-facing player came back
front-facing and the side-on runner came back side-on. A batch can now name
`ref_only` — the frames it looks at — separately from the frames it replaces:
the back view is drawn from the one genuine back-view frame, the charging runner
from a front-facing zombie, and each brute wound pair from its own A frame so the
injury cannot wander between the two.
Also stopped colour-matching a REPOSE. Fitting a differently-posed frame onto the
old one's colour statistics is what greyed out the serve shorts; when the pose
changes the generator's own palette is the better answer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three from the playtest, all code:
Every side-on sprite is DRAWN facing left, and nothing ever mirrored them, so a
zombie walking right moonwalked — obvious the moment they close on you across
your own half. They face where they last walked now. The front-facing ones (the
turned CPU, the machine, a chapter boss) have no left or right and are left alone.
The racket zombie holds a position and only moves when the ball makes him, so a
time-driven walk cycle flicked him between two frames on the spot. His stride
steps off ground covered, like the brute's — standing still is standing still.
And the pack no longer stands on the body twitching for as long as the summary is
up: once it appears they break up and shamble away up-court. Measured — feeding
at 3s, dispersed to 3m by 6.5s.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two that kept failing land as two-figure sheets — a dragged pole or a
held-up chair is wide enough that four to a sheet either merge into one frame or
run off the canvas whatever margin the prompt asks for. The chair spectator came
out the best of the lot (0.15-0.24 to 0.42-0.52).
Also caught x_boyrun3 and x_boyrun4 sitting at the old fidelity next to the two
frames of the same run I had already redrawn — my batch list only covered half
the cycle.
The roster now averages 0.341 inner-edge density across 111 character sprites,
against 0.237 for z_mach2. What is deliberately left alone: the chapter bosses,
the judge, the ball machine and the weapon icons — Oliver named those as the ones
that were already right.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four more batches: the racket zombie's two wound stages with their stride twins,
the umpire and the ball boy's sprint, his mop-carry walk and his mopping cycle.
Detail roughly doubles on all of them.
Also stripped the last dozen key-colour pixels off p_die5's rim — edge pixels
only, because a racket's strings are legitimately pink and a blanket filter
would eat them.
The brute's wound stages and the chair spectator are re-running as smaller
sheets: four figures carrying a dragged pole or a held-up chair either merge
into one frame or run off the canvas whatever margin the prompt demands, so
they go two at a time.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both full sets through the same pipeline: stances and ball-bounce, serve toss,
smash and both swing frames, the four-frame run, and the five-frame death. The
opponent's ten frames with them. Inner-edge density roughly doubles across the
board — the serve frames go 0.22-0.32 to 0.41-0.54.
_regen.py retries a rejected batch itself now (default three attempts). A
rejection is usually just a bad roll — two figures drawn close enough that the
slicer sees one frame, or a limb off the canvas — and re-rolling fixes it without
me sitting in the loop.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
assets/_regen.py plus a batch file: build a reference sheet from the sprites in
the repo, ask for a HIGH-resolution redraw, slice with _fine.py, colour-match
each frame onto the one it replaces, install. The point of the whole thing is the
style block — the old prompts specified coarse art and got it.
It refuses to install a bad sheet. Wrong frame count, any figure touching a
canvas edge (that is what clipped z_f1 in the first place), or a redraw that is
not measurably finer than what it replaces, and the batch is left alone with a
reason printed. Re-running a batch is the fix; three of the first eleven needed
it. The bar is 1.25x inner-edge density rather than 1.5x, because a sheet that
already has blood on it starts high and a genuinely finer wounded body still
scores a smaller gain.
Done so far: both shamblers and their wound skins, the brute's walk and its two
sets of wound stages, the steward and his wound skins, the turned CPU.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
I had been measuring the wrong thing. The complaint is not the PNG's size or its
upscale factor, it is how fine the DRAWING is — how many pixels make up the
figure, and whether there is room in it for teeth, fingers, seams and folds. On
that, my own prompts were the cause: every sheet this session ordered "every art
pixel is a big square block about 8 screen pixels across" and "flat blocks of
colour, no shading", which is a specification for coarse art. The generator
followed it exactly.
New recipe, proven on the runner's four frames: ask for HIGH resolution — "build
the figure out of 90 to 110 pixels of height instead of 40" — and enumerate the
detail it should spend them on (teeth, sunken eyes, individual fingers, tracksuit
seams and side stripe, torn edges, ribs, laced shoes, four or five shades per
material with hatched shading). Inner-edge density went 0.15-0.22 to 0.43-0.49;
z_mach2, which Oliver holds up as the good one, sits at 0.24.
z_f1 has been clipped on its right edge since it was made — the generator ran the
figure off the sheet. The new sheets demand 150px of empty margin at every edge
and it is whole now.
The rest of the roster is still the coarse style and wants the same pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audited every sprite by detecting the art-pixel BLOCK SIZE each one was actually
drawn at. The set splits cleanly in two: 29 sprites are 112 art-pixels tall on a
2-pixel block — literal 2x upscales, carrying half the detail — against 200-224
for everything drawn since. z_f3 and z_bw are in the first group, z_chairt and
z_mach2 in the second, exactly as Oliver called it.
Regenerated at full detail so far: the player's idle pair, toss and smash (he is
on screen the whole game, and his own run and death frames were already fine, so
he was mismatched against himself), plus the two wounded shamblers and the
runner's three frames — where the wound skins were the worse case, since their
newly drawn second frames were full-detail and their A frames were not.
New pipeline step for it, assets/_fine.py: detect the generator's art-pixel grid
and snap to it, common-scale in ART pixels off a chosen anchor frame (so a smash
with the arm overhead stays legitimately taller), quantise back to the sheet's
own palette, and de-fringe the key colour twice — the generator does not always
return a pure magenta and the halo survives as a rim on a green court.
Still half-resolution, in order of how much they are seen: the brute's seven
wound stages, the seven opponent frames, the racket zombie's two wound skins,
and x_ump / x_boyrun1 / x_boycarry2 / x_boycarry3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two of the same mistake, both called out.
The wounded feet still did not move because a mirror cannot move them: flipping
the legs swaps WHICH leg leads but keeps the two feet in the same two places, so
the spacing never changes and the body still slides. Measured it — mirrored feet
sat at -35/59 against the original's -34.5/60.5. What a walk actually needs is
the PASSING pose, feet together under the body, so the gap opens and closes.
Drawn now for all three wounded skins: 75->65, 95->68, 79->69 px between the
feet. (The mirror also clipped the steward's hanging arm into a floating
fragment, which is what Oliver spotted. Gone with it.)
The death was one sprite scaled down, which miniaturises a man rather than
killing him. It is five drawn frames now — arched back, knees, hands, face down
in the pool, a flat heap — each genuinely shorter than the last (100/84/60/47/20%
of standing height, common-scaled off one sheet) and each drawn at its own
height. No scaling anywhere in the sequence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pair alternated but the feet stayed put: the generator had swapped the arms
and torso and redrawn the same legs. Measured it rather than squinting — the
lower third of the two wounded frames differed by 3.7% and 4.6%, where a healthy
walk pair differs by 60-70%. That is a body sliding, which is exactly what Oliver
saw.
Regenerated with the stride as the entire brief (33% for the shambler), and where
the generator would not move them, mirrored the leg band in place instead: a
side-on walk swaps its stride under a horizontal flip of the legs alone, same
pixels, same palette, no dice roll. The coated shambler and the steward are that
(28% and 31%).
Audited every wound pair the same way while I was in there — the racket zombie's
two stages and the brute were already fine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The old death was a red tint and a shake for half a second, then a puff of gore.
That reads as the sprite having a moment, not as being eaten.
Now it takes four and a half seconds and it is a crowd doing it. You are dragged
to your knees fighting it (new frames, p_die1/p_die2, alternating), blood coming
out of you the whole time — a constant spray and a stain that keeps widening,
with the wet work on a loop underneath. Every body on court drops what it was
doing and converges: each claims a spot in a ring around you, kept from wherever
it was standing, so they crowd in from all sides instead of stacking on one
pixel. Then you are pressed flat into the ground while the ring closes over you —
you are drawn INSIDE the crowd's z order, so the ones between you and the camera
hide you exactly as they arrive — and what is left is the mound, with them still
feeding over it when the summary finally fades in.
Also: the wounded shamblers were stepping their two frames at half the cadence of
the healthy four, which reads as frozen next to the crowd they walk in. Same
clock now. (They were animating — verified frame by frame — just too slowly.)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last two bodies still shuffling on two frames. Both get their in-between
strides, generated against their own art and fitted per channel onto it.
The chair-down stagger was walk frame 3, which a four-frame cycle would have
walked straight through — it is its own sprite now (z_sdown), picked by state
rather than by where the cycle happens to be.
Also fixed a strobe that predates all of this: z_c2's racket was drawn BLUE
while every other frame of the same zombie carries a brown one, so it flashed
once per cycle. Remapped onto z_c1's racket palette.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It was furniture in the most literal sense: bolted to one spot, three hits from
scrap, and when it died it bled and a ball boy came out to mop it.
- It trundles to a new spot every seven seconds or so, and CANNOT fire while it
rolls. That is honest (a rolling cart cannot aim) and it is the window you use
to close on it — but it also means it stops being a landmark you can ignore.
- Six hits, not three, and it wears them: dented at two thirds, split open and
sparking at one third, smoking where it stands once it is that far gone.
- It comes apart as MACHINERY. No blood decal, no gore mound, no mop boy — a
flash, sparks, torn metal and a column of dark smoke over a scorch mark. Smoke
is a new particle flavour that rises and spreads instead of falling.
Damaged sheets keep the healthy cart's footprint: the generator drew the smoke
plume into the sprite, which would have shrunk the cart as it took damage, so
the plume is cropped off and the smoke is drawn procedurally where it can move.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A wound skin was a single still, so anything hurt froze on one frame and slid
across the court. Every skin has its …b twin now — the stride swapped, stepped
off the same clock as the healthy walk — for both shamblers, both racket-zombie
stages, and the steward, who had no wounded state at all.
The steward also died like a shambler at 3 hp, which made a bigger body read as
decoration. Five now, with a wound skin to show the second half of that. hpMax
is recorded on every body that has hp, so "hurt = show the wound" is one rule
instead of a per-variant constant to remember.
First pass at the wound frames was rejected on sight and deserved it: the
generator drew small figures with fine pixels on a GRADIENT background, and a
nearest-neighbour downscale to sprite height shredded the outlines. Regenerated
two at a time, drawn large, chunky pixels, flat background, hard black outline —
then fitted per channel onto each frame's own healthy twin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Last body still walking on two frames, and the slowest thing on court, so the
lurch was the most visible. Same treatment as the player: the whole cycle
regenerated in one pass, then fitted per channel onto the mean and sd of the
frames that stay — the four wound skins — so a healthy brute and a wounded one
are the same colour (mean error about 1 of 255).
Its cycle steps off DISTANCE WALKED, not time, the same rule its wound lurch
already used: at brute pace a time-driven cycle is pure foot-slide. Four frames
over the same metre, so the cadence is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two frames is a lurch, and he is on screen the whole run — during a horde he is
the only thing moving. Regenerated the whole cycle in one pass rather than
bolting two new frames onto the old pair, so the four are consistent with each
other, then fitted them per channel onto the mean and sd of the frames that
stay (idle + swing): mean error under 3 of 255, so he does not change colour
when he starts moving.
The pick is frame-count driven, not hardcoded — the opponent has no run3/run4
and stays on two, and the cycle RATE is unchanged either way: more frames
subdivide the stride, they do not speed it up.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A new ordinary body between the shambler and the brute: a stadium security
steward in a hi-vis vest, 2.15 m, three hp, a touch slower. Deliberately the
mildest thing in the advanced pool — no wound skins, no special rule to learn —
so he can unlock early (game 4) and thicken the ordinary crowd, which a mix of
two shamblers never did. A fifth of horde spawns come out as stewards too, so a
grown wave is not one body dying at one range.
Sheet generated against the existing shambler walk (assets/_sheet_dwalk.png),
sliced to a 4-frame cycle, so he shambles as smoothly as the crowd he walks in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The umpire is situational again. Each line is tied to the thing that justifies
it — a new variant appearing, the court filling, one crossing your baseline, a
game lost, a horde, a changeover — behind one shared cooldown, so several
triggers at once still yield one remark. The random timer pool is gone, the
interval went 26s to 55s, and ump_out_of_order, ump_let and ump_no_appeal are
deleted.
"Visually the ball is not yet at the player but somehow the player already is
hit" was real and measurable. The hostile-ball hit test reused the parry's
hypot(dx, dz*0.8), and weighting depth shrinks it: contact fired while the ball
was still 0.68 m up-court and drawn up around his head. It has its own
unweighted test now, and contact lands at 0.25 m with the ball on his chest.
The parry keeps its weighting — a racket sweep really is wider than it is deep.
On "everything is a tad too small": measured first, because higher-resolution
sprites are only half the answer. At RES 3 the player rasterises into 170
device pixels from a 112px source — upscaled 1.5x — while a far shambler is
downscaled to 78. So near bodies needed resolution and far bodies needed the
camera.
Camera: pushed back and lengthened (camZ 42->48, camF 580->760) to flatten the
perspective so distant bodies grow relative to near ones rather than everything
zooming, camY raised for a steeper look-down, horizon re-centred. Far shambler
26->30px, boss 39->45px, player unchanged, court in the same band.
Sprites: the original sheets were still in the repo at full resolution, so
nothing needed regenerating — IDLE_TARGET_H went to 224 and every sheet was
re-sliced. The sheet-to-output mapping was never recorded, so it was recovered
by matching sliced frames to existing assets by image signature: 58 came back
from sheets, the 29 without a confident match were 2x-upscaled to stay on the
shared scale, since a sprite off that scale renders at half height.
Tennis untouched: 50 hits / 0 whiffs. 45-game soak clean, all four chapters.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five reports from Oliver's second playtest.
The parry was too generous — at 6 m of arm with a 0.34 s window the shot flew
into reach on its own, so any tap once it was vaguely close worked. 3.4 m /
0.2 s / 1.7 m reach now: a tap at 4.5 m is rejected and costs blood, 3.2 m and
in returns. The boss telegraphs in 0.5 s against the machine's 0.9 and throws
harder, because a boss should be worse than furniture.
His three balls were decoration: fanned ±1.9 m across space, so only the middle
one could ever reach you. They are a timed salvo now — three shots 0.42 s apart,
each aimed at where you are at that moment. hurtGrace still caps a whole volley
at one point of damage, which is what it is for.
Every chapter was the same boss. Three designs cycling now, each with its own
sheet, stride, throw pose, wrecked skin and wrecked-throw skin: the
GROUNDSKEEPER, the LINE JUDGE, the CHAIR UMPIRE with his chair strapped on. The
chapter card names them. Designs are data in BOSS_ART, so a fourth is a sheet
plus a row.
Healing announces itself with a first-aid tin and a red cross instead of a 1.4 s
flash. And 14 new umpire lines: ten sarcastic asides on a jittered timer, four
hooked to specific beats. He still has not acknowledged that the match became a
massacre, so he keeps officiating it.
Two traps found and written into the pipeline notes. Sprite loads now carry
?v=SPRV, because python http.server sends no cache headers and the browser
caches 404s — a name added before its PNG exists stays permanently missing for
that browser. And a scripted replace that silently didn't match left drawZombie
on the old hardcoded boss names while everything else moved to BOSS_ART; the
built-in fallback hid it perfectly, all four designs rendering as design 1 with
no error. Assert your rewiring edits matched.
Tennis untouched: 30 tapped points, 50 hits / 0 whiffs. 45-game soak clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six reports from Oliver's first playtest of the boss build. Two mattered.
A boss sprite turned up in the shambler crowd because z_B1.png and z_b1.png
are the same file on a case-insensitive filesystem — writing the boss frames
silently overwrote the 'b' shambler's first two frames, and `ls` only ever
showed the original lowercase name. Restored from HEAD~1, boss frames renamed
z_boss*, and the rule is now in the asset pipeline notes.
The parry was unreachable, which killed the entire new mechanic: P.swingT is
only set in keyPressed's `phase === 'rally'` branch, and splatter sits in
pretoss almost all the time, so every tap aimed at an incoming shot tossed the
serve ball instead of swinging. An incoming shot inside parryArm now owns the
tap in every phase, with its own generous reach and window — this is a phone,
one thumb, against something aimed at you. The three fail cases (too early,
too late, no tap) are what keep it a timing skill. The swish no longer eats
shots: it runs first in keyPressed and was swallowing the very ball the parry
was about to return.
One flame ball killed a 16 hp boss, and that was a bug rather than tuning. A
flame ball is not consumed on contact (full pierce, so it can cut a lane) but a
boss is not removed when hit, so the same ball re-wounded it every frame. Boss
bodies now take a contact cooldown and flame spends itself on them. Chapter 1
costs 4 flame balls or 16 clean smashes, and bossHp goes 10 to 16.
The boss had no wounded state while throwing — correct, the wound skins were
standing poses only. New sheet gives all four damage states their own overarm
pitch. The ball machine's four painted rects are replaced with a real sheet,
idle and firing, keeping only the muzzle glow procedural because the telegraph
has to read at 20px tall. And the player's blood no longer sits on him as a
painted layer: live drip and the screen-edge wash, nothing else.
Tennis untouched: 30 tapped points, 50 hits / 0 whiffs. 45-game soak clean,
all four chapters firing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Steps 4 and 5 of the late-game depth plan.
The ranged channel. A rogue ball machine gets wheeled onto the far court every
four games: it never walks, never crosses your baseline and never scores, so
it is a standing threat you choose when to deal with — the target-priority
decision the game was missing. Its shot is a real arc solved like a serve, not
a flat bolt, so it reads coming in and the swing window has something to meet.
Three outcomes: a swing inside the volley window returns it as your own ball
flying up-court (an extras clone, so "a parry is a weapon" cost nothing to
build), the panic swish bats it away, and letting it reach you costs blood.
The swish path is load-bearing, not politeness: in pretoss the player is
rooted and cannot swing at all.
Contact still kills outright — that stays a separate channel, and killPlayer()
is now the single death path for both causes so the run summary and the best-run
write cannot drift. Health is drawn on the man: procedural spatter at fixed
anchors, a live trickle, and a red wash from the screen edges. No bar. Healing
is winning a duel, which finally gives the duel a reason to be wanted.
Chapter bosses every ten games, with their own sheet — a colossal zombie
groundskeeper hauling a ball hopper, coming apart through arm-off, ribs-out and
headless-still-throwing. Chapter 1 is 10 hp and 2.7 m, chapter 4 is 34 hp and
3.67 m. It outranks the set and duel beats at a clear, because a boss walking
into a standing wave is unreadable. And it fixes the economy: a chapter-4 boss
costs 12 bombs or 100 rounds or 34 side balls against a per-type cap of 9, so
no single weapon can take one and a banked mixed arsenal is mandatory.
The racket zombie is demoted to an occasional opponent, as asked — appearing
every won game is exactly why he read as the boss.
Five silent bugs found on the way, all in HANDOFF: a blast deleted a 34 hp boss
outright (isBoss() exists to stop that class recurring), the machine's hp did
nothing because it fell through to the one-shot fallback, the boss both scored
and fired its card, the machine gated the hand-clear, and a boss died with a
shambler-sized gore burst.
Tennis untouched: 30 tapped points still 50 hits / 0 whiffs. 45-game soak clean
with all four chapters firing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All eight issues from Oliver's 35-game retest.
The freeze (1 and 6 — one root cause): the movement block, pose update
included, lived inside `if (G.phase === 'rally')`, so every 'over' beat and
every pretoss stranded the player mid-animation while the zombies walked on.
It now also runs whenever splatter is in any phase but 'toss' — 'toss' is the
aim, and the thumb is busy. Tennis keeps its old gating.
The rest:
- the mop boy is waved off the sudden-death duel and barred for its duration
- triple-serve clones inherit the serve's topspin gravity, so the wings stop
floating while the centre ball dives
- "Time." belongs to the changeover's own countdown, not to whatever beat
happened to expire while the breather ran
- the tutorial illustration is a finger pressing and pulling back now, not a
curved arrow that read as a scroll
- aiming holds the pretoss stance; the smash plays when the shot fires
- GAME N sits on the scoreboard permanently
HANDOFF records all eight as closed with what the fix was, plus two ways a
soak rig lies to you and why the CPU-return benchmark can't be reproduced
from the number alone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- The crowd sat in the dark, worst of all in splatter mode where you spend the
game. Both crowd frames get an identical gamma lift (means 30/14/39 ->
42/22/56) and the rot tint drops 0.5 -> 0.26. The curve MUST stay identical
across the two frames or the clap swap flashes brightness; _sheet_crowd1.png
keeps the pre-lift original.
- The splatter hint led with the tap when pulling is the whole gesture. It now
reads PULL BACK TO AIM - RELEASE TO FIRE, and after 1.5s of sitting in the
serve stance untouched a ghost thumb slides back while chevrons grow up-court
on a loop. Pull back, shoot forward is an inverse relationship that a line of
text cannot carry on its own.
- The mop boy has three sheets instead of two: the push cycle now plays ONLY
while he is stopped and working, a new carry sheet walks him upright with the
mop shouldered, and the run sheet stays for the tennis-mode ball fetch. He was
mopping thin air the entire way to the stain.
Sheet note: frames 2 and 3 of the carry sheet merged (a mop handle bridging the
gap), so it is cut at the true column gaps plus the thinnest column inside the
merged pair, then common-scaled — same technique as the brute stride sheet.
26-game soak clean. Serve returns 133/150 = 88.7% +/-2.6%, 30 human-tapped
points = 50 hits / 0 whiffs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The freeze first: G.mgT only counted down inside `G.phase === 'rally'`, so
anything that moved the phase mid-burst — a scheduled duel calling setupPoint —
stranded it above zero forever. mgT > 0 is one of keyPressed's early returns, so
no tap could serve again, and the yellow shape over the player's head was
drawMgRacket's muzzle flash still being drawn. Fixed at the root (the clock ticks
in every phase, only the pellets are gated on rally) and semantically: starting a
duel cancels any burst, because the duel is tennis.
The mop boy now does an actual round: walk to the biggest nearby stain, stand on
it and push the mop while the cycle animates in place, scrub it out, move to the
next, then leave. Gore fades ~3.5x faster while he is stopped and working than
when walking past, so the stain dies under him instead of fading as he drifts by.
boyMaxJobs caps the round so a kill streak can't employ him forever.
Applause: the clip is 4.16s and always plays in full, but every cheer() passed a
shorter beat, so the stand sat down while it was still audible. Floored at the
clip length. New, much bolder clap frame — the re-roll came back brighter and
bluer, which would have flashed the whole stand, so it is colour-matched to frame
one per channel on mean and sd before quantising.
Net and umpire were both wrong since forever: netW was the singles-stick width
(10.06) against a court drawn to the doubles lines, so the net stopped 0.46m
short each side; the chair's near legs stood 0.45m inside the sideline. Both now
sit exactly on the doubles line.
26-game soak clean, no stalls. Serve returns 134/150 = 89.3% +/-2.5% with the
wider net (the bar is inside one sigma), 30 human-tapped points = 50 hits /
0 whiffs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every animation in this round gets real frames. The procedural squash-and-sway
on the wounded brute read as fake on sight, and that gait is where the comedy
lives.
- Each brute wound stage now has its own 2-frame lurch (z_gw/z_gwb, z_gw2/z_gw2b,
...). The generator gave every B frame a dragged pole that only stage 1
actually carries, so it would have blinked in and out each step — stripped by
keeping the largest blob, and where the pole touched the body, by cutting on
column density (a torso is dense, a dragged pole is a thin diagonal).
- Frames step off DISTANCE COVERED, never elapsed time — boy.dist for ball boys,
z/bruteStride for brutes. A time-driven cycle moonwalks the moment speed and
frame rate disagree, which is exactly what "he slides faster than the animation
suggests" was. Change a speed now and the legs keep up on their own.
- One boy, one slot. sendBoy no longer replaces a boy already on court: that was
the appearing and disappearing, since every kill teleported him back to the
wing mid-stride. New mess is appended to his round instead. He now walks for
10.5s a crossing, and there is a lot of mopping to do.
- The applauding crowd is a real two-frame animation (seated / arms up), swapped
per column on its own phase. The old "smooth wave" still shifted each column by
a FRACTIONAL dy, and nearest-neighbour resampling at a fractional y re-snaps a
column's pixel rows every frame — that boiling beside the HUD is what read as
the scoreboard flickering. The board art is 99.9% opaque; nothing was ever
showing through it. With the crowd calm the frame delta is now 0.0%.
Benchmarks: 136/150 serve returns (90.7% ±2.4%, the 11/12 bar is inside one
sigma), 30 human-tapped points = 50 hits / 0 whiffs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Fire never spread in play. The flame ball's full pierce wiped its own lane, so
the bodies that would have caught were already gone. It now ignites everything
it FLIES PAST (TUNE.burnBallR), with a wider ignition and spread radius. One
flame serve into a scattered 10-wave kills 9, nine burning at once.
- Wounded brutes slid along like decals: every wound stage is a single frame and
the brute now spends most of its life wounded. They get a procedural lumber
instead of eight more sprites — translate + squash, no rotation, so the pixels
stay on the grid, and the amplitudes scale with the projection or the whole
thing goes sub-pixel at range.
- The "scoreboard flicker" was the crowd: the cheer bounce was per-column
Math.random() every frame, i.e. TV static, showing through the scoreboard art's
transparent ornate edges. Now a smooth per-column sine on idleT.
- Ball boys: mopping is the default in splatter mode now, and the boy is half
again as tall — at 1.2m he read as a distant speck. The running fetch-boy
survives for tennis, where there is nothing on the grass but a ball.
- Changeover 7s -> 5s, with the mop crossing timed to fill it.
- Save-to-homescreen: a bloody tennis ball, wired up as apple-touch-icon +
favicon + webmanifest. The Dockerfile now ships the manifest and gives it its
own MIME type via an exact-match location (a bare `types {}` block would have
replaced nginx's whole map and broken .js and .png with it).
Benchmarks re-run: CPU returns 67/72 serves (93.1%, above the 11/12 bar — a
20/24 sample in between was flub noise), 30 human-tapped points = 50 hits /
0 whiffs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The three foundations first, since everything else leans on them:
- G.modal is the only thing that stops the world. update() early-returns on it
so no zombie steps, no ball flight, no spawn timers run while a popup is on
screen — you can read it without being eaten. Dismissal swallows the gesture;
the pointerdown dismisser is registered only on pointer:fine, because on touch
pointerdown beats touchstart and a shared handler cleared the modal and then
served with the same thumb.
- UI in config.js is the accent palette. The arcade primaries were shouting over
the artwork; the gore reds deliberately stay loud.
- Every canvas font string now goes through F(). Bare `monospace` resolved to
Courier — a slab serif — which was the "generic serif" bug, not taste.
Then: weapons are only ever WON. deliverTin() has exactly one caller now
(resolveInterlude('P')); the old game schedule calls the DUEL instead, fired off
proceedAfterClear() where the court is guaranteed empty. Four popups (splatter
tutorial with a generated illustration, first duel, weapon received, horde),
wCap 9 with the tin amounts tunable, a 5-hp brute that comes apart over four
wound skins, fire that ignites the neighbours and chains outward, a three-stage
death where you become the biggest gore mound on the court, and a 7s changeover
with a ball boy who finally has a mop.
New art: assets/logo.png, x_howto, z_gw2-4, x_boymop1-4, plus assets/_key.py for
standalone art (chroma-key + crop, no frame splitting, no forced sprite height).
Benchmarks re-run and holding: CPU returns 23/24 serves, 30 human-tapped points
= 50 hits / 0 whiffs, 26 simulated games with no exceptions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Feedback batch from a phone playtest:
- Winning a game no longer auto-sweeps the court. The survivors stay and must
be killed by hand (no new spawns, no scoring during the mop-up); only once the
court is clear does play advance to the changeover / next game / set. Replaces
sweepCourt() with a G.clearing gate + proceedAfterClear(). "CLEAR THE COURT".
- Classic shamblers (a/b) now soak one splat-quality wound before they drop
(shamblerHp=2) — swapping to new z_aw/z_bw wound skins that bleed the thin
dynamic trickle plus a small impact pop. Only the runner stays one-shot glass;
brute/boss unchanged. Ball, pellets and triple side-balls all respect hp.
- Winning a SET shows a centered milestone card (a real pause) before the horde;
one reusable drawCard() now backs both it and the reworded sudden-death card.
- Player is no longer pinned to the baseline: free to advance up-court on
keyboard (bounded at the net); touch keeps the baseline (drag is the serve-aim)
and the horde/GUN burst re-brace there.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Serve:
- Reverts the single-tap auto-serve. Firing a ball still needs the two-stage
toss+fire gesture — but a tap mid-rally now RE-ARMS the toss (throws a new
ball up) with the previous ball flying on as a ballistic clone, so you can
toss the next serve without waiting for the last to leave play.
Blood:
- Wound bleed cut further: sparse (most frames emit nothing), thinner spread,
lower rise, short-lived, capped low — a fine trickle, not a spray.
- Repainted z_gw / z_cw1 with clean stumps and almost no painted blood (the
torso gash and shirt spatter are gone), so the dynamic trickle is the blood.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Stand at the baseline: in splatter the player is pinned to the baseline
(a turret, side-to-side only) — the duel is exempt so it keeps free depth.
- Zombie 1:1 duel now plays like real tennis: no free-aim flick, no drag
reticle; lane aim + the toss-then-hit serve (gate on !G.interlude in
doHit/hitServe/keyPressed/canAimServe).
- Rapid serves: the two-stage reticle serve now fires mid-rally too — a ball
still in flight is banished to a ballistic clone so the next serve launches
at once. Serve as fast as you can drag-release.
- Wounded skins for multi-hit foes: brute (arm off) and racket boss (arm off,
then headless) swap by remaining hp, with a graceful fallback to the healthy
sprite. The headless boss is drawn a head shorter so the shoulders line up.
- Dynamic gore: the blood is NOT painted in — open wounds gush live particles
every frame (arm stumps + a neck geyser on the headless boss), absurdly, and
bounded so it self-regulates.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tins now accumulate on a courtside rack (cap 3) instead of replacing each other; a PLAIN slot banks specials for later, and GUN is a charge you pop for an auto-fire burst. Tap the rack or press 1-5 to choose what your serve throws. A fresh delivery auto-equips but never overrides a deliberate PLAIN, and the 1:1 sudden-death duel forces plain balls.
HUD gets real art: generated pixel-art weapon icons, an ornate Wimbledon scoreboard PNG backing the live text (no more code-drawn boxes on top), and a bundled Silkscreen pixel font replacing the system serif. Rack tiles get padding, count-over-label alignment, and an infinity marker on the ball.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire the new assets/audio/*.wav samples over the synth SFX:
- Ball hits: per-side tennis-player / tennis-opponent on serve & return,
now with a stadium reverb tail (same convolver as umpire/aww).
- Crowd: tennis-applaus on your points/games (louder on big moments);
tennis-aww when the opponent scores or you're eaten (reverb tail).
- Ambient bed: tennis-ambient-loop from first tap, 500Hz lowcut, low vol.
- Zombie music: zombie-mode loop when splatter kicks in (800Hz lowcut),
fades out for the 1:1 sudden-death duel and back in after.
- Headshots: random headshot-1/-2 on every zombie kill (killZombie).
audio.js: sfx() one-shot player w/ rate+reverb opts, loopBed() beds with
duck/fade, aww() wrapper, hotter reverb send on the umpire voice.
Also: normal opponent aiFlub 0.09 -> 0.14 (a little easier to beat).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Umpire chair moved courtside (UMP_X 5.6, was 7.1 — which projected to
x=272 on a 270px-wide portrait canvas: literally one pixel off-screen).
Tap-cheat coords follow the constant.
- Generated pixel-art night crowd (x_crowd.png) replaces the tier stripes +
random dots; drawn in 16 columns that bounce independently while the crowd
cheers. Old procedural crowd kept as the loading fallback.
- Generated courtside sponsor boards (x_ads.png): ACEADE, TOPSPIN & CO,
VOLLEX, NETTO GRIP, SLAMWICH — tiled at native aspect so lettering is
never half-cut at any viewport width. Replaces the 'SPLATTER OPEN' text.
- One rot tint over crowd + boards driven by the shared paletteT() (new
helper), so the stadium dims into splatter and brightens back for duels.
Verified: fresh origin; tennis + splatter screenshots (boards tile cleanly,
crowd reads, tint dims, umpire on-screen beside the court); umpire triple-tap
works at the new chair position; tennis serve benchmark 33/36 across two runs
(first 12 was flub noise); no console errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- RES 2 -> 3: phone screens now DOWNSCALE the canvas (crisp pixels) instead
of upscaling it (soft). Umpire-tap coords un-hardcoded from RES=2.
- Flatter lens: camZ 36 -> 42 with camF scaled per orientation (406/580) so
the PLAYER stays the same size — far-court zombies render ~25% bigger and
finally read as individuals from the baseline.
- Smoother animation: 4-frame walk cycles for both classic shamblers and the
runner's sprint (new sheets, same style anchor; z_a1-4, z_b1-4, z_f1-4).
Same cycle frequency — frames subdivide the stride. Render falls back to
2 frames per variant until its sheet loads.
- Tiered gore (GORE_MUL): the more advanced the zombie, the more absurd the
mess — classics 1x (base bumped 24 -> 30 particles), runner 1.8x, shield
2.5x, racket boss 3x, brute 4x (120 particles, a scatter of stains, an
instant size-3 mound).
Verified: fresh origin, canvas 1440px, umpire triple-tap works at RES 3,
all 4 anim frames cycle at the old stride rate, brute kill = 120 parts /
2 stains / size-3 pile, far-court lineup screenshot clearly readable,
portrait layout numbers correct (810x1440, camF 580), tennis 11/12,
no console errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Oliver's six complaints, addressed:
1. Opponent never came — a racket zombie now spawns on EVERY game you win
(if none alive); endHorde no longer double-spawns
2. No wave-cleared feeling — sweepCourt: at game end the surviving wave
collapses, every game gets a real beat (gameBeatT 1.6 / changeover 4.5),
and 'GAME N' announces the next wave when play re-arms
3. Serve-posture lockup during machine gun — root cause: any 'over' beat
expiring into setupPoint() -> pretoss during auto-fire (no movement outside
rally, no serving during horde/GUN). The over-expiry now returns auto-fire
modes to 'rally', never the serve loop
4. Forced bomb spending — specials commit ONLY on deliberate serves: flick
(or arrow-aimed keyboard serve) fires the tin, plain tap keeps it; serve
prompt teaches it ('FLICK FIRES THE BOMB - TAP KEEPS IT')
5. Auto-aim — free-aim: the flick vector maps to a landing point (length =
depth, up to 22m), zombies within zSnap 1.4 magnet the shot, otherwise the
ball lands exactly where flicked; taps keep the old auto-target assist;
flicks now also work at the toss apex
6. Zombies walking through you — option (a): touch is honest. eatenR 0.9
(brute +0.2) matches the visual sprite overlap
Phase 13 — SUDDEN DEATH interlude: court clear + un-dueled racket zombie +
about to serve -> 'Quiet, please. Play will resume.' / 'Sudden death.' One
point under REAL tennis rules (line calls live, the boss plays every ball,
spawns paused). Win (line call, double bounce, or regicide by any weapon) =
the next tin delivered on the spot; lose (out, or its return passes you) =
a game on their scoreboard + an instant 5-shambler surge. One duel per
opponent. New lines: play_resume.m4a, sudden_death.m4a.
Verified in browser: horde 'over' beat -> rally (lockup dead); game win ->
sweep + boss + beat -> SUDDEN DEATH chains naturally; duel lose path (GAME.
THEM., surge 5, scoreboard), no re-duel, fresh-boss duel, bomb regicide ->
tin on the spot; tap serve keeps stock, real touch-flick consumes + lands
at the flicked point, magnet snaps when near; eaten at 0.85m; tennis 11/12;
no console errors. PLAN.md/HANDOFF updated: plan v2 complete.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sheets generated with the existing zombie sheet as the style anchor (edit mode);
sliced to z_f1/2 (runner sprint), z_g1/2 (brute + net-post club), z_s1/2/3
(shield zombie, chair up / chair dropped stagger), x_tin (black new-balls tin).
assets/_slice.py is now committed (chroma-key + de-fringe + segmentation) so the
pipeline survives scratchpad wipes; HANDOFF updated.
Sprites are NOT wired into render.js yet — Phase 11 does that after Oliver
approves the sheets (plan's design checkpoint).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Camera shake (G.shake, decays over 0.35s; HUD stays steady via transform reset)
- One white flash frame (G.boomFlash overlay)
- Scorch crater: near-black stain the boys mop 10x slower — the lawn scars
- Hitstop 0.1 -> 0.18
- Umpire loses composure: 'Order. Order, please.' (new order.m4a), rate-limited
so bomb chains don't spam him
Verified: explode kills cluster of 4 (MASSACRE x4 chains), shake/flash set and
decay clean, 2nd bomb order-call suppressed, 1s of mopping erases blood (0.9->0)
but not the crater (1->0.84); screenshot mid-shake; no console errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Spawn director: gap and court cap scale with games finished (runGames);
game 1 is three lonely shamblers, late runs approach zSpawnMin/zMaxCap
- Changeover after odd games: 4.5s forced breather, no spawns, no serving
(melee still works), boys mop, umpire calls 'Time.' (new time.m4a)
- 6 games = a set; set end launches the horde (replaces the splatGames>=2 trigger)
- Zombies taking a game sets shameGame (phase 9 withholds the delivery)
- Eaten = the run is over: GAME, SET, MASSACRE summary with games/sets survived
and best run (localStorage); HUD shows sets in splatter (SETS·GAMES PTS)
- spentBall() helper so dead-ball paths can't clobber a changeover's beat
- T surface: splatPoint + explode exposed for verify scripts
Verified: game-1 cap 3 held 25s; changeover blocks taps+spawns then resumes;
6 games -> set+horde -> racket zombie; runGames=6 tempo ~6 spawns/10s;
eaten writes best; tennis regression 11/12 CPU returns; no console errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A single shot that splatters ≥2 zombies now escalates: DOUBLE KILL /
TRIPLE KILL / MASSACRE ×N flash, louder crowd, and a new umpire line
(double_kill / triple_kill / unbelievable m4a, recorded say -v Daniel).
ball.kills tracks the streak, reset on every fresh shot (doHit + hitServe).
zombies.js imports cheer (now exported from gameplay). The splatter pace
cut is now TUNE.zPierce (0.75, was hardcoded 0.5) so a hot ball plows
through a lined-up lane instead of stalling on the first kill.
Verified via window.T: a low ball down a one-side lane kills 3→TRIPLE and
5→MASSACRE ×5 with escalating flash, streak resets on the next serve, the
three new lines serve 200 and match the existing m4a format, no crash when
the ball dies mid-streak, no console errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Win your first game and the applause cuts off mid-clap. The palette drains,
the umpire lowers his voice ('Quiet... please'), your opponent turns — torn
red polo, dragging his racket — and shamblers come over the billboard wall.
The scoreboard rewrites CPU to THEM. 'Play.'
- zombies.js: shamblers walk your way; quality shots splatter them (gore
decal + particle burst + wet synth splat), weak sitters only stagger them
back; one crossing your baseline is a point for THEM
- tennis scoring machinery keeps running untouched: fifteen, thirty, deuce,
and 'Game, set... massacre' on a game win, all in recorded umpire English
- ball boys mop the gore they pass (their destiny fulfilled)
- transition: palette lerp, scripted spawns incl. the turned opponent,
new recorded lines (quiet_dark, remain_seated, play, new_balls)
- moan ambience, drained splatter palette, all zombie params in TUNE
Verified: transition triggers on first game win, splat/stagger/baseline
scoring all correct, dead-ball flow feeds fresh serves, tennis mode
regression clean (6/6 returns, 6/6 points resolved).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>