Steps 1-3 of the late-game depth plan, after Oliver's 50+ game playtest.
"Sometimes the racket zombie doesn't seem harmable" was real, not a feeling.
Three of the five rack slots did nothing at all to him and said nothing about
it: TRIPLE side balls skipped him, GUN and horde pellets skipped him, fire
couldn't ignite him. BOMB meanwhile deleted him outright ignoring his hp. All
of it now routes through one wound() with a TUNE.dmg* table, so damage is
proportional to the body and a future 20 hp boss needs no new code. Fractional
damage banks on zb.chip so whole points only come off hp — the wound skins
switch on hp thresholds. His contact radius goes to 0.7 m, because part of
"unharmable" was just missing him. Melee still skips him and that's correct:
he's on the far side of the net and melee can't span it.
The HP bar is gone. It was the only floating UI on a body, it was on screen
for nearly the whole run, and nobody knew what it meant — while the wound
skins already say the same thing in the language every other multi-hit enemy
uses. A one-per-run modal explains what he is instead. Skins and bleed anchors
now read a fraction of hpMax, since his hp scales with the run.
And the plateau: every difficulty dial bottomed out by about game 12, so games
12-50 were arithmetically identical. Body cap, spawn gap, shamble speed,
variant mix, boss hp and horde size all keep climbing now, calibrated to track
the old curve through game 12 rather than replace it. One deliberate exception:
shamble speed was time-based and maxed during game 2, so the early game is
genuinely gentler now — zSpeedPerGame if that reads flabby.
HANDOFF carries the full spec for steps 4 and 5 (ranged attacks with player
damage, then chapter bosses) including the decisions we settled, so the next
session can pick it up cold.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
m.in (0..1 over TUNE.modalIn, 0.34 s) is the entrance clock, ticked inside
update()'s modal early-return — the one thing that animates while the world
is frozen. Icon and title fly in from the right trailing two ghost copies as
a speed streak, the body lines cascade in from the left one after another,
everything on easeOutBack so it overshoots and settles rather than
decelerating politely into place. The dim washes in over the first third. A
descending noise sweep on arrival (swish is parameterised now — same six
lines of Web Audio, opposite reading) and a low pock as it lands.
Every x is rounded: a fractional x on a nearest-neighbour scaled image
re-snaps its pixel columns, which is the boiling that once read as HUD
flicker.
Dismissal is gated on the entrance: while m.in < 1 dismissModal swallows the
gesture but doesn't clear the modal, so a tap already in the air when a popup
appears can't skip it — and swallowing rather than returning false is what
keeps that tap from falling through into a serve.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Oliver vetoed the free movement between points on sight: he never walks
freely during play, so being able to only between points reads as the game
breaking, not as a fix. Reverted to 'rally' only, and the rule is written into
the settled-feel list so it doesn't get re-invented.
The frozen changeover is answered his other way instead — the dead wait too.
`held` in updateZombies stops their advance, their steering and the eat check
for the breather; they still bleed and burn, they just stand there, because
the umpire called time and it is still a tennis match. The path where this
actually bit: losing a duel scheduled on a changeover beat spawns five fresh
shamblers and THEN starts five seconds you have no agency in.
The stuck pose was never about movement — 'run' is the one pose expirePose
doesn't expire, and runT stops advancing outside the block, so a beat that
began mid-stride welded him to a single frame. Reset it and it's fixed for
every phase.
And he could walk off the screen in ordinary play, not just during the beat I
had added: P_BOUNDS.x is 6.5 m, portrait shows about ±4 m at the baseline.
The xVis clamp already existed but only applied during auto-fire; it now
always applies, floored at the sideline so every legal ball stays catchable.
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>
Everything it specified shipped long ago, and the parts still worth knowing are
already folded into HANDOFF. Keeping a stale plan around only invites a future
session to treat it as live spec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rewrote HANDOFF.md around what a next session actually needs: what the game is,
how it flows, the invariants you have to know before touching anything, the
settled feel decisions, the regression net with its real numbers, the asset
pipeline with the generator's known failure modes, and the hard-won gotchas.
Five rounds of chronological "feedback round" sections are folded into that
structure — the history is in the git log and the memory file, not here.
Deleted POLISH-PLAN.md: all fourteen items shipped. PLAN.md is left in place but
marked historical at the top of HANDOFF.
Corrected two stale facts carried in the old handoff: RES is 3 (not 2, which
would have thrown off the umpire-cheat coordinates) and UMP_X is 6.05 (not 5.6).
Added an OPEN ISSUES section with Oliver's eight findings from the 35-game
retest, each with a diagnosis from reading the code rather than just the
symptom. Two of them (player frozen between games, sprite freeze after the
horde) look like one root cause: the movement and pose block sits inside
`if (G.phase === 'rally')`, so every 'over' beat freezes the player mid-frame
while the zombies walk on. The triple-serve lob is confirmed: the wing clones
integrate at flat gravity while the real serve carries up to 2.3x from topspin.
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 draw meter is a fixed bar across the bottom, ~90% of the width and 10px
tall, with the kill notch on it. It was a 26x4 stub floating on the apron by
the player's feet, which is nothing under a thumb at arm's length. The serve
hint is suppressed while pulling since they share the slot. pullFull 95 -> 130:
at 95 every pull maxed out by accident, so the meter never meant anything.
- The aim magnet took the nearest body ALONG the lane, so a close shambler a
little off-axis stole shots clearly aimed past it. It now picks the best
ALIGNED body, with TUNE.aimTie of slack in which a nearer one still wins — a
straight-ahead lane still eats the nearest body first, which is the settled
feel. aimTie: 1 restores the old behaviour exactly, for A/B.
- Bomb radius 2.5 -> 4.2 (2 kills -> 5 in a scattered ten-wave), a 120-particle
eruption, a ring of thrown stains and a full-size pile. The blinding white
frame is now a brief warm bloom — it was hiding the very gore it announced.
- Rack tiles 22 -> 30 tall, and the touch band is padded above and runs to the
screen edge below. At 22 logical px they were ~30 CSS px on a phone, well under
the ~44 a thumb expects; now ~62.
26-game soak clean with heavy bomb use, arrays stay bounded. Serve returns
136/150 = 90.7% +/-2.4%, 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>
PLAN.md: two-stage aiming, runner-glass, fps-60, and no-engine decisions added
to the don't-re-litigate list; PWA/App Store noted as a next-arc candidate.
HANDOFF.md: status covers the post-plan rounds; gotchas gain the synthetic-touch,
1/60-stepping, port-hold, and zombies[]-mutation lessons.
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>
Five phases, each independently shippable with verify recipes: tighter
shooting loop, multi-kill umpire fanfare, procedural gore piles, bomb
balls delivered by ball boys, horde waves with machine-gun racket.
Racket zombies deferred to a design checkpoint (Phase 6, not first cut).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
splatT now ticks while in splatter mode and scales spawn speed by
1 + min(1.5, splatT/70). A zombie within 0.7m of the player ends the
run: gore burst, 'game, set, massacre', dark EATEN. overlay with final
games and a tap-to-restart (gated 0.7s so death-spam doesn't skip it).
Zombies keep shambling on the end screen but no longer score. Also
verified the two prior unverified items: quick-serve cadence (0.25s
between balls, flick = instant aimed serve) and flick aiming across
the net (far/near lane selection works as designed). HANDOFF updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Done (unverified): faster serve cadence in splatter (overT 0.25, serve from
where you stand, flick = instant aimed quick serve), aim-anywhere targeting
relative to the player. Not done: zombie speed ramp (G.splatT exists but is
never incremented), eaten-on-touch death + restart screen. Full state and
plans in HANDOFF.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>