THE LEGS. Measured as lower-third silhouette difference between the two stride
frames: the groundskeeper, whose walk has always been right, is 0.80. The seller
was 0.017 — the same legs twice, which is why he hopped along on one of them.
Both sheets regenerated with the stride as the entire subject (one foot planted
ahead, the other trailing, and they SWAP): seller 0.42, champion 0.67.
Three things the champion's re-rolls proved, all of them the same lesson —
THE REFERENCE IS WHAT IT COPIES. Anchored on his own one-legged frame it drew
that pose again. Given a working walk from another boss alongside him "for the
legs only" it blended the two and returned a slim side-on walker, then a
pot-bellied man in cream. One character in the reference, one character out —
and pick the keeper by EYE, because the metric happily crowned the side view.
THE CHARGE. It rushed, then withdrew a fraction of what it covered, so it ended
the fight parked on top of you: dodge or die every two seconds with no window to
hit it, and nothing but a hitscan laser quick enough to use the window there
was — Oliver, "they come too close, they seem unbeatable". A rush now RETREATS
to its line afterwards, and to keep it from being toothless the other way (from
the band it ran out of clock 8 m short) the rush is TIMED rather than paced: it
covers whatever ground the retreat left in bossChargeT, so every charge arrives
and every charge has the same readable run-up. Measured over 30 s: five rushes,
each one reaching you, 13% of the fight in your face instead of all of it.
And the back room moved off the whole splash onto an invisible box in the TOP-
LEFT CORNER. Five taps in the middle of the screen — which is where your thumb
goes on the way to PLAY MATCH — now do nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The weapon-test cheat sat on the SCOREBOARD, where a single tap is the miss-log
overlay — so the thing you tap to read a log was also the thing that opened a
sandbox mid-run, three taps in, by accident. That tap does one job again.
The cheats now live behind five taps on the splash logo, where nothing marks
them and nothing in play can reach them:
WEAPON TEST the old sandbox. It stocked only the BARREL weapons, on a screen
called the weapon test — it stocks every weapon in the game now,
eight of the nine slots.
BOSS TEST a portrait of every chapter boss, built by asking bossArt() what
each chapter gets, so the list cannot drift from what a run
actually spawns. Pick one and you land in a real fight at that
chapter's game number with the full arsenal at ×99.
Neither is a special mode: both ride the transition out with laserScene's trick
(force the turn, then step the ten-second cinematic out in one frame) and hand
you the ordinary update loop, ordinary scoring, ordinary death. A test that
plays by different rules tells you nothing about the game.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- opaque canvas: the compositor stops alpha-blending 2.5Mpx at 60Hz
- settled modals and the death summary idle at 20-24 fps; play untouched
- TUNE.lowPower caps RES at 3 (-44% pixels/frame), flippable live
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- praise was firing at RELEASE, half a second before impact (measured:
32-frame flight, word on frame 0) — it now arms on the ball and pops
on the first body it meets, or its first bounce
- DOUBLE KILL / MASSACRE / RETURNED rise off the thing that earned them,
as world-anchored pop texts — the announcement band is for milestones
- the band got rows: a card can no longer print over the flash line
- GAME N flash deleted (the scoreboard bar already says it), CHANGEOVER
folded into the countdown panel
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- opening CPU beatable in a game or two (softer cap, more whiffs, rubber band)
- modals are for calm courts only; live-court news is a card now
- the transition howto is gone: a ghost hand teaches the pull, the toss,
the apex and the swing, each on the exact struggle it answers
- a shot barrel keeps your weapon and glows its slot instead
- switching weapons clicks, the live tile is unmistakable
- barrels come faster the deeper the run goes
- the umpire triple-tap cheat is deleted
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- The opaque menu no longer has the whole court rendered behind it at full
resolution — minutes of battery burn nobody could see.
- Stands, ads, rot tint, court, lines and the run-off are one cached blit,
rebuilt only when the palette actually moves; the clap ripple stays live.
- RES derives from devicePixelRatio (capped at 4), so a 2x phone stops
painting double the pixels it can show.
- One shared noise buffer for every synthesized sound — applause alone was
allocating ~200 fresh buffers per cheer.
- The touch-steer path stops re-measuring a fixed canvas rect per move event.
Skipped as not-quick: the z-sort closure churn and swap-remove splices —
real but small, and each is a behavior-preserving refactor for another day.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The cold-start complaints — late fonts, zombies missing from the stats
screen — were one header. server.py sent no-cache, must-revalidate for
everything, so a fresh launch revalidated ~230 sprites and three fonts
before the game had a full set of frames. Assets under /assets now cache
for a year when the URL carries ?v= (changing the file changes the URL,
which is what SPRV is for) and a day when it does not; the fonts get a
?v= too. HTML and JS stay no-cache, so a deploy is still visible.
Two client-side halves of the same report: a dropped sprite request used
to be permanent for the session — the sheet stayed undefined and the
zombie drew as some other design — so each one gets one retry. And the
splash now holds its buttons back behind a loading bar until the last
sheet has landed and both faces are ready, instead of letting you open
the stats screen while half of it is still in flight.
The bar is driven by setInterval, not rAF: rAF does not fire in a hidden
tab, and an app that loads while backgrounded would come back to a frozen
bar and a menu with no buttons on it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ranked on KILLS IN A SINGLE RUN. Games survived is the same information at a
tenth the resolution — most players land between 5 and 20, so that board is a
wall of ties broken by who got there first. Games ride along as the tiebreak
and as a column.
The image is python:3.12-alpine running server.py now, not nginx. The board
needs one process that can write, and everything the nginx conf did here — the
no-cache headers and the manifest MIME type — is a few lines of stdlib. /data
must be a persistent volume or every deploy resets the ladder to empty.
A name belongs to whoever took it first: the client mints a random id once and
the row remembers it, so a stranger cannot overwrite your score. The id never
leaves the server. That is the whole security model and it is enough.
The prompt to sign the board appears on exactly one occasion — a run that beat
your own best kill count, on the tap that leaves the death screen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The court art had three faults: a hard-edged pale panel sitting on the near
half, mown stripes that stopped and restarted where the court began, and the
blood pool stranded behind the baseline where a portrait phone cropped it off.
Redrawn with the stripes running unbroken the full depth and the stain out on
the court where it can be seen.
The picture also read as zoomed in, which was not the art but the crop: at 4:6
`cover` was throwing away a third of the width on a phone. The sky is extended
to 1:2 instead, so the whole arena survives the crop.
Menu palette rotated off olive towards a cool desaturated blue-green with much
quieter borders, and the boxed BEST RUN / WORLD RANK bar is gone from the
splash — it sat on the best part of the art to say what the two buttons under
it already said.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The menus were placeholders next to the art. Three screens now: a title over
an empty, dimmed, blood-stained court; a MATCH SETUP screen where each court
wears meters read straight off its own bounce numbers; and BODY COUNT, which
needed the game to start measuring anything at all.
stats.js keeps one capped array of per-run records — all time, last match and
seven days are three filters over it rather than three counters that can drift
apart. Kills file under the body AND the weapon: killZombie only ever knew an
index, so every damage loop stamps the cause on its way past.
The tutorial is gone. Marvin explained what the in-game modals already teach,
and his stage is where the stats live now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four things Oliver caught on the phone.
The screen title is the ANNOUNCEMENT face now. A menu heading is a thing that
shouts, the same register the canvas gives SET and CHAPTER — it was wearing the
HUD font, which is the voice for scores and key hints. Bangers carries its own
weight, so it takes size and air instead of Silkscreen's letter-spacing, plus
the padding under it he asked for.
Her toss and smash were shrinking the whole woman. The slice anchors frame 0 to
224 px, and frame 0 was the toss — whose arm is straight up, so the BODY got
squeezed to make room for it. This is the trap already written down for the
raised racket; it just moved to a different sheet. Re-sliced the retained sheet
at the men's toss height (250) instead, so toss/smash/pull come out 250/293/191
against his 250/320/213 and her body matches her own idle again. No re-roll.
The pre-serve ball did not follow the MIRROR. drawChar flips the whole sprite
when a server faces left, so dodging during the bounce swapped his hands over
and left the ball hanging out by the racket — one `* sv.facing` on the offset.
It was wrong in the men's world the whole time, it just took a second body
standing next to it before anyone saw it.
And she reaches further out and higher than he does, so BOUNCE_IDLE's pair is
overridable per tour now (0.46/0.78 against his 0.36/0.68). Measured off the
drawn hand by eye, which is the only way those two numbers have ever been set.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The court screen picks WHO as well as WHERE now: two tiles above the court
rows, men's or women's singles, remembered like the court is. No new stage —
three rows and a pair of buttons already fill a phone, so the tiles go side by
side and the tap count from splash to play does not change.
Everything downstream is one line: drawChar already built every frame name off
a single prefix, so SK() appends '_f' when the women's tour is picked and falls
straight through to the men's frame for anything without a twin. The horde, the
officials and the ball boys are shared — they are staff, not the draw — but the
turned CPU is not: a male corpse opposite a female player reads as a bug.
Her frames came back as a different woman in every batch — blonde in one,
auburn in the next, greyscale line art in a third — because each was anchored on
the MAN whose pose it was. "newart" repaints the character and "state" is welded
to the corpse palette, so neither could say "the same living woman, a new pose".
That mode now exists, and every batch anchors on her own settled frame.
The death is drawn to frame 3 and recoloured after it. The generator will not
draw a dead woman in any wording worth using; frames 4 and 5 survive that
because by then she is flat and then a heap of cloth, so they are the men's
drawing with the blue shorts painted into her white (see assets/_die_f.py). The
collapse still falls 224/159/117/103/43 straight through the handoff.
Her first bounce frames reached to the shoulder, which is a catching pose, not
patting a ball — redrawn low rather than bending BOUNCE_IDLE to fit a wrong
drawing, so both tours share one apex and the ball meets her palm.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
He died at every chapter boss because he could not move, and there were two
independent reasons for it. The movement block is 'rally'-only — a settled feel
decision the six boss AI styles were written around — and a boss is fought from
'pretoss'. On top of that canAimServe() is true through a boss fight, so his one
finger was already spent on the slingshot: there was no gesture left over that
could have meant "move".
So it is a SIDESTEP, not a walk. One committed step on a cooldown, carrying
invulnerability that beats both damage channels — the chip damage in hurtPlayer
and the instant-death contact test. The rule about walking stands untouched.
The gesture is picked by WHICH WAY the drag leaves the deadzone, decided once and
never revisited for that touch: down-ish is the pull the game already teaches,
sideways is the step. No timing window, no invisible mode, so "one gesture means
one thing" still holds. The finger-steer modes are untouched — stick.aimable is
false in a horde, so the drag is the absolute steer it always was. The apron demo
now alternates PULL DOWN and SWIPE TO DODGE, and Marvin has a page for it.
The rest of the playtest list:
- Bangers is bundled beside Silkscreen, and the register IS the category. The
quiet face keeps the HUD, the rack, the control hints and RESUMING IN; the loud
one takes SET, CHAPTER, EPIC!, PERFECT and every free-floating flash. Floating
text gets one shared y and a drop shadow, so it survives crowd, clay and blood.
- Modals queue instead of being dropped. That was why the trainer and the weapon
had to be crammed into one card after a won duel; the heal gets its own popup
with the x_cross tin over it — a sprite loaded since that merge and drawn by
nothing.
- weaponModal's first-sight gate moved inside the function. The barrels gated
themselves and the tin ladder did not, so four weapons re-explained themselves
every three games while a barrel weapon explained itself once.
- Ten rack slots. Past five it grows a second row upward rather than shrinking
ten tiles under the thumb target; slots 0-4 stay welded to the bottom edge.
- The boss win holds for its own 3.6s and says EPIC!, with no GAME n NEXT line —
the bar under the scoreboard already prints that permanently. That bar went
from 6px to 7px, which is the smallest type in the game becoming words.
- The turn punches the camera in on him and the ground shakes harder at each beat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Oliver's tuned numbers become the defaults: beamY 0.95, beamLen 23, beamW0 0.08,
beamW1 3.1, beamOriginLift 0.44, beamBorder 0.18.
Three knobs earned their keep on the way there.
beamLen is the DRAWN length now, not only the kill range. The drawn length was
solved every frame so the far end landed on the middle of the ad strip, which
made this a control that visibly did nothing. One meaning beats one clever
behaviour -- and it pairs with beamY, since a lower plane meets the ads sooner.
beamOriginLift replaces beamOriginY: the apex is a lift ABOVE the plane rather
than an absolute height, so lowering beamY brings the near end down with it
instead of leaving it hanging at chest height with the beam sloping away.
beamBorder exposes the edge width, which was hardcoded.
And the playground is back, deliberately NOT paused. The first version froze the
world so the picture would hold still, and then nothing redrew, so every value
changed did nothing -- a still frame you cannot change is worth nothing. The
bodies are held still individually and spawning is off instead.
Also: a boolean living in TUNE has to be tested with >= 0.5. The panel steps
every key by 0.02, so one nudge set beamBand to 0.02 -- still truthy -- and the
renderer switched variants under him mid-tune.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The cone is fully opaque now: solid colour, deep blue at the edges and near-white
down the core, dimming as it runs away. Depth is carried by BRIGHTNESS instead of
by alpha, so it still reads as receding without anything showing through.
That settles an argument the translucent version could not win. Its opacity and
how well you could tell above-the-plane from below-the-plane were the same dial
pulling opposite ways: readable enough to show the split and it veiled the whole
cone, subtle enough to look right and there was nothing to see. Solid, a body
above the plane is drawn on top of it and a body below is hidden behind it. The
trade is that you cannot see the crowd through it, which is the thing to judge on
the phone.
The `alpha` parameter went with it rather than being left in the signature lying
about what it does.
laserScene is gone. T.pause(true) stays -- it freezes the world but keeps drawing,
which is worth having whenever a still is what you need to look at.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
T.laserScene() (or #laser on the URL) drops into splatter with six bodies of
different types at known depths, the laser armed, a pull held, and the world
paused.
T.pause(true) freezes the WORLD but keeps drawing, which is the whole point: a
value changed in devtools shows on the next frame while the scene stands still.
The old recipe halted rAF, which freezes the picture too and is no use for
judging how something looks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A test mode for trying the new content without playing twenty games to reach it.
Three taps on the scoreboard toggles it, three more turns it off, and a single
tap is still the miss-log overlay. Same shape as the umpire triple-tap cheat it
sits next to.
It OVERRIDES the difficulty director rather than editing it, so the wave is the
same whether you opened it in game 1 or game 40 and the run keeps running
underneath: 8 bodies, a fixed 1.15 m/s shamble, a spawn every 1.6 s, and
pickVariant pretending to be at game 20 so every body type can turn up. All four
barrel weapons sit on the rack and are topped back up each frame, because
committing a burst spends the whole tin and a sandbox that runs dry is not one.
A barrel rolls out every 14 s so the pickup itself is testable too.
The third tap must NOT toggle logShow: taps one and two already toggled it twice
and it is back where it started, so toggling again left the miss-log overlay
switched on behind you every single time.
Also records the trap that cost a debugging round getting here: index.html loads
main.js?v=N, and fetching 'main.js' does not bust that URL -- so main.js stayed
stale through a reload while every other module was fresh, and the tap handler
appeared to do nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rack had five hardcoded slots where the index WAS the weapon, spelled out in
five places: SLOTS, slotStock, WDESC, drawRack's three parallel arrays,
deliverTin's w+1, and bare wsel === 1|2|3 / G.weapon === 4 tests across five
files. config.WEAPONS is the one table now, G.rack holds up to four {id, n} in
the order you GATHERED them, and slot 0 stays BALL. Everything reads through
slotId/slotStock/slotEntry, so nothing else knows how the rack is stored.
A fifth weapon is always allowed, per Oliver: it rides slot 0 as G.temp until
spent, then slot 0 goes back to the infinite ball. A barrel arms on the spot and
is spent quickly, so refusing it would mean a barrel you shot did nothing. And a
spent weapon LEAVES its slot -- with gathered slots, an exhausted weapon holding
one is a quarter of the rack you can never pick up into. (The old rack kept its
empties dimmed, which was right when a slot meant "BOMB goes here".)
Four new weapons, and they are all the ONE existing pellet loop. firePellet(w)
stamps the WEAPONS row onto the round -- r, dmg, hp, pierce, boom, ignite, col,
speed, life -- so the beacon cannon, flamethrower, laser and rocket needed no new
array and no new update; the break after the first body was the only thing making
a pellet single-target. All four go through the same isBoss branch, which is why
none can silently no-op on a boss the way three of five once did. Measured:
gun/beacon/laser/rocket reach past 16 m, the flamer is a ~6 m cone that ignites.
G.mgT stays the mode timer for all of them, so the eighteen !(G.mgT > 0) guards
across five files did not have to change. kind:'burst' auto-fires at its own rate
for len seconds; kind:'shot' holds the same braced stance and fires one per tap.
The barrels reuse the ball machine's shape: a rolling, shootable non-body. All
three of the registrations that fail silently are done -- blocksClear (loot must
not gate the hand-clear), the contact guard (furniture cannot eat you), and
hitR/GORE_MUL/zombieH. It scores nothing.
The pink had to become procedural. The chroma key deletes every pixel where red
and blue both beat green by 40, so a magenta drum and a magenta laser pistol both
came back as hollow outlines -- 19% and 15% solid against 50-56% for the icons in
other colours. The drum is grey steel with hazard chevrons and every bit of its
pink is the halo drawBarrel paints; the laser went electric blue. Both are solid
now (92% and 58%), and the rule is written down in HANDOFF.
tinTriples 3 -> 10 as asked, and the four dead TUNE ammo constants are gone --
nothing read them once the table landed, which is a panel value you can tune all
day for no effect.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The lane used to come from the pull's own direction, so aiming up-court was
always a downward drag. Every shot ended lower than it began, the next touch
starts where the last one ended, and over a horde the thumb walks itself into
the iOS bottom edge — where a pull down is Reachability and the system takes
the gesture out from under you. A web page cannot refuse that.
So touch-down picks the lane now (a ray from his chest through the thumb) and
the pull after it is power and nothing else, in whatever direction there is
room for. Aiming is what drags the thumb UP the screen, because that is where
the shamblers are.
The cost, and the thing to feel: the lane locks when the thumb lands. You can
no longer sweep the aim while drawn.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The slingshot pulled back, which meant every shot ended with the thumb lower
than it started — and the next touch begins where the last one ended. Over a
horde it walks itself into the bottom edge strip, where a pull down is
Reachability and iOS takes the gesture. A web page cannot refuse that.
So the drag now points AT the target: the thumb ends high and drifts up-screen,
away from the edge. It also matches the flick-swing, which always aimed this way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The menu stage was the same logo on the same backdrop with two buttons, so it
read as the title screen failing to advance. The buttons move onto the title
screen in place of the control list, which is the tutorial's job now.
Death no longer reloads the page. The reload was an honest full reset, but it
also tore down the AudioContext — so the theme restarted from silence every
time you died, when it should carry straight on from the death summary into the
title screen. restartRun() puts G, P, O, ball and score back from a
structuredClone taken at module load (all plain data, so the clone is the whole
job and a field added later is covered for free), and the two modules with
timers of their own reset themselves: every zombies array plus spawnT/moanT/
mgTick, and input's held keys and half-finished drags, which would otherwise be
inherited by the next run's first frame.
The overlay is hidden rather than removed so it can come straight back, which
needs its own [hidden] rule to beat the display:grid on #intro.
The theme starts on the first tap and never restarts: loopBed's beds.has guard
already makes every later menuMusic() a no-op.
Verified: one background-music fetch across launch -> run -> death -> title ->
second run, and a full second run through the transition after the in-place
reset.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tutorial was borrowing a court tile, the in-game how-to icon and a weapon
icon, none of which were drawn to be looked at. Four purpose-made 2:1 panels
now: the serve with a tapping hand, the slingshot pull with chevrons into a
zombie, the weapon rack, and the floodlit duel. They get the full card width
above Marvin instead of being squeezed into the text column beside him.
The menu wears the title logo instead of a Silkscreen headline, and the two
buttons wear generated plates — START GAME bloodied, TUTORIAL nearly clean.
The lettering stays Silkscreen on top: generated type is a lottery and would
not match the rest of the HUD.
Two things the art needed. Quantise the panels at 128 colours, not 64 — big
flat fields with saturated accents is where a small palette shows, and 64
turned the tennis ball beige. And find a plate by its BRIGHT BORDER, never by
the corner colour: one plate came back on a near-black margin, the other on a
transparency checkerboard that differs from itself everywhere and matched the
whole canvas.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The overlay is a four-stage machine now — splash -> menu -> {courts, tutorial}
— swapped by data-stage while the backdrop and card frame stay mounted, so the
live court never flashes through between screens.
Court tiles SELECT only. Tapping one used to start the match on the spot, which
gave you no way to compare the three and no way back out; START GAME and BACK
are their own buttons now. Headlines are 15px against the 11px body, so a screen
reads as a screen rather than as a list.
The tutorial is a data table: a Marvin pose, an optional picture already in
assets/, and his lines. Seven pages covering tennis, the turn, the slingshot,
the rack and the duel. Marvin is four new frames off x_boymop1 — waving,
pointing, shrugging, and clutching his mop in terror.
The theme no longer starts at boot. A title screen that plays at you before you
have touched it is rude, and the autoplay policy would have held it silent until
that first tap anyway.
Death returns to the menu instead of the title. The reload stays — it is the
honest full reset — and a sessionStorage flag is what picks the stage on the way
back in.
Machine-gun rounds are tennis balls, so they are drawn as balls and take the
perspective. A fixed 4x4 fillRect was exactly as big at the far baseline as at
your feet, which read as square confetti pinned to the screen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
background-music.wav loops under the title screen and the court picker, ducks
out when a court is picked, and fades back in at the death summary — the end
screen is a menu too, so the zombie bed ducks out as this one comes in.
It starts at BOOT, not on the first tap, so the title screen already has music.
That needed loopBed to call audio() before its already-started early return: the
theme plays into a context the autoplay policy holds suspended, and the call
that wakes it is a second menuMusic() from the first tap, which used to return
at the beds.has guard without reaching AC.resume().
fadeBed also now records a duck for a bed that is still loading, and loopBed
starts that bed silent. An 8 MB file ducked mid-fetch would otherwise come up at
full level, seconds later, over the match. Same latent bug applied to zombieDuck.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three courts, picked on a second start-screen stage: CENTRE COURT (grass),
TERRE ROUGE (clay) and THE SLAB (hard). A court is a row in config.COURTS and
four PNGs — render() never learns their names.
Each row carries two palettes, because every court still rots: what "drained"
means on orange clay is not what it means on green lawn. Mow stripes became a
flag rather than a colour — recolouring the lawn and leaving the bands on still
read as grass. Bounce is two numbers copied into TUNE on selection, so the three
bounce sites in gameplay.js and the T panel stay untouched: grass skids low and
fast, clay sits up and drags, hard is the old baseline.
Per-court crowd sheets and ad strips. Each court's clap frame is generated from
its OWN seated sheet, not from x_crowd2 — pairing one court's crowd with
another's applause cross-fades between two different crowds.
The picker is a second panel in the existing #intro overlay, rows built from the
table. Last pick is remembered and comes up pre-highlighted; 1..N or Enter on a
keyboard.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pulse was drawStatic being told a height instead of reading one. _slice.py
common-scales a sheet, so a crouched frame is genuinely shorter — 211 px against
its neighbours' 224 — and forcing every frame to the same on-screen height
stretched it back up, so the body swelled and shrank once per stride. Worst on
the brute because it is the biggest and slowest. The rule already existed for the
wound stages; it now applies to every frame, once, which also settles the
shamblers and the steward (their cycles vary by up to 9%).
A chapter boss now dies in its own beat instead of under the card announcing it:
the world drops to a third speed for 1.2 s, the screen kicks, a shockwave ring
blows out through 90 extra particles and six stains, and the CHAPTER DOWN card
holds off until the pieces have landed — about 1.4 s, measured.
Cards can hold off in general now (`wait`). First cut only delayed the card's
countdown while still drawing it, which is worse than no delay: caught it in a
screenshot before it shipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Researched it instead of guessing again. Launched from the homescreen, WebKit
resolves the DYNAMIC viewport and every env() inset once against a provisional
geometry and does not revisit them until the geometry is "exercised" — a
rotation. That is the whole bug, and it also explains why listening for a signal
could never fix it: nothing fires, so there is nothing to observe.
Two known workarounds, both applied. Standalone has no toolbars, so 100vh is
correct AND stable from the first frame — dvh only exists for the browser's
disappearing toolbars, so it now applies only there. And toggling viewport-fit
off and back on at boot forces WebKit to recalculate, without the user rotating
anything.
Countdown moved up off the ad boards — the strip's own projected line is its
ceiling now, so it stays in the stands whatever the phone does to the layout,
with a 1px shadow to carry it over the crowd.
Sources: gist.github.com/fozzedout/5e77925381991a9570151550992baf14
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Am I on the right version?" needs an answer on the device, not in a terminal.
The browser cannot see git and push = deploy leaves no build step to inject a
sha into, so the marker is the served main.js's own Last-Modified — it moves on
every deploy with nothing to remember to bump. Match it with
`git log -1 --format=%cd`.
Cache-buster bumped with it: without that the phone keeps the old main.js and
shows a stale build line, which is the exact thing this is meant to catch.
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>
- 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 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>
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>
Runner counter (no new weapon needed — one you don't hold when it shrieks
can't save you): the sprinter is now a GLASS CANNON — any ball contact at
any quality bursts it (main ball and triple-serve side balls; melee and
pellets already killed it). The hold-to-aim auto-lock targets the most
advanced zombie = the charging runner, so touch-and-release is the panic
button. Plus runnerVMax 4.2: the late-game speed ramp is capped so runners
stay humanly reactable.
Battery (iPhone drain):
- TUNE.fps = 30 (T-panel tunable, 0/60 = uncapped): frame-skip cap in the
RAF loop — halves paint work on a 810x1440 canvas, the dominant cost.
dt clamp raised 0.033 -> 0.04 so 30fps doesn't run 1% slow-motion.
- Gore piles pre-rendered to per-pile cached canvases (rebuilt on growth /
orientation change): 1 drawImage per pile per frame instead of ~26
ellipse paths — late-game courts had ~800 path fills per frame.
- matchMedia('(pointer: coarse)') hoisted out of the render loop (was
allocating a MediaQueryList every pretoss frame).
Verified: weak (pow .05) ball kills a runner; max-ramp runner v = 4.2
exactly; piles render from cache (screenshot, pl.cv set); frame-cap
arithmetic yields clean every-other-tick at 60Hz RAF (RAF itself is paused
in this pane — logic deterministic); tennis serve benchmark 30/36 across
runs (flub-band; diff is provably tennis-neutral); no console errors.
Co-Authored-By: Claude Fable 5 <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>
THE FREEZE (root cause found): the game-winning kill fires INSIDE the zombie
update loop — killZombie -> splatPoint -> gameOver -> sweepCourt guts the
zombies array mid-iteration, the loop reads zombies[i] === undefined, throws,
and the un-rescheduled RAF chain dies: a permanent hard freeze. Crowded courts
(later games, brutes) make the mid-loop game-winner likelier — hence 'freezes
around game 3 when the fat zombies arrive'. Fixed at the root (null guards in
every loop whose body can cascade into a sweep: zombie walk loop, meleeSwipe,
explode, pellet + extras inner loops) AND made frame() reschedule RAF FIRST so
no single bad frame can ever kill the loop again.
Aim awareness: the reticle now appears the moment the thumb lands (mag 0 =
auto-target the most advanced zombie, red lock immediately). A pure HOLD
(>250ms, no drag) releases into the auto-target serve; dragging aims as
before; the serve prompt switches to 'RELEASE TO SERVE / RELEASE FIRES THE
BOMB - DRAG BACK TO CANCEL' while aiming.
Duel presentation: the interlude now opens with a 2.4s intro beat — serving
blocked, centered card (SUDDEN DEATH / ONE POINT - THE LINES ARE LIVE / WIN
IT FOR THE NEXT TIN), and the palette lerps back to the TENNIS colors for
the duration of the duel (duelLerp, 0.8s each way) — order visibly restored,
then the rot returns.
Machine-gun steering: absolute now — the finger x IS the player x, zero lag
(was sign-quantized velocity). Clamped to what the camera can see
((W/2-14)/proj-scale — portrait's world bounds are wider than its viewport),
keyboard steering clamped the same during auto-fire.
Verified: the exact pre-fix crash scenario (game-winning kill mid-loop,
9 zombies + brute) survives and sweeps clean; intro blocks taps then the
duel plays; hold-release fired at the auto-lock and won a tin end-to-end;
screenshot shows tennis-green court + centered card + GAME 2; MG jumps
P.x to the finger in one frame, clamped; tennis 12/12; no console errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deliveries cycle BOMB -> TRIPLE -> FIRE -> GUN (G.deliveries indexes the
ladder); one tin at a time, a fresh delivery replaces unspent stock. All
weapons stay one-thumb: the serve auto-consumes, zero new inputs.
- Triple serve (x3): one real serve fans two ballistic clones +-6deg (extras[]
in zombies.js — NOT the reverted rapid-fire: each fan costs a proper serve).
Side balls respect variant rules (wound brutes, clank off chairs, pass the boss)
- Flame ball (x3): kills on ANY contact regardless of pow, chair ignored,
full pierce (splatter pace tax undone), ember trail, white-hot render
- Machine gun (8s): horde auto-fire as a pickup — no serving, drag steers,
no assist, spawner keeps running, ammo bar drains on the HUD, then the
serve loop resumes
- HUD pips per type (black/yellow/orange) at the bomb slot; flash names the
tin ('NEW BALLS: FIRE')
Verified: ladder cycles across 5 deliveries; triple spawns 2 extras and the
fan kills flanked targets; pow-0.1 flame ball clears shambler+shield+brute in
one pass with TRIPLE KILL chaining; GUN tin arms at changeover, blocks taps,
streams pellets (kill in 5 frames), expires back to pretoss; tennis 11/12;
no weapon-flag leaks into tennis; 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>
Oliver's feedback on the feel pass:
1. Rapid-fire serving was way too easy. Reverted splatter serving to the
proper model: a tap does the toss ritual (apex timing = quality), a
flick is a quick aimed serve. Dropped the multi-ball barrage and
removed the extraBalls plumbing (array, update, render, hooks). Melee
swish and zombie homing stay. If multi-ball is wanted back, it should
require a proper serve per ball.
2. The player was stuck in the serve/swing pose when a horde started
(the triggering shot's pose lingered) — startHorde now clears it.
3. A horde re-triggered immediately: machine-gun kills scored points, so
games were won mid-horde and the next kill after it ended won another
game → instant new horde. A horde is now survival: it doesn't score
(splatPoint skipped while G.horde), so it can't chain. After a horde
you must win a full game again to summon the next.
Verified via window.T: a tap tosses (not an instant serve) and the full
toss→serve→rally completes; melee still kills adjacent shamblers; pose is
idle/-1 at horde start; no scoring during a horde and the next kill after
scores 15 without restarting it; tennis 11/12, bomb consume, homing all
still green; no console errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From Oliver's feedback round:
- Racket melee: any swish (tap) in splatter cleaves shamblers within
TUNE.meleeRange (1.7m) — a close-range survival tool. Works during a
horde too (tap = swish only, still no serve). The racket-zombie boss
(Phase 6, not built) is immune by design.
- Zombies home in on you: on YOUR half (z > netZ) they now drift toward
P.x instead of walking a straight lane; far half stays an aimless
shamble.
- Multi-ball / rapid fire: splatter is now a shooter — every tap/flick
fires immediately (dropped the toss ritual there), and serving while a
ball still flies stashes the old one into a new extraBalls array so it
keeps arcing and killing (cap 6). Refactored the kill into a shared
killZombie() with no ball coupling, used by ball hits, pellets, melee,
and extra balls.
- Machine-gun mode clarity: confirmed free-walk (drag/arrows move you,
auto-assist off) and tap-doesn't-serve during a horde; added a
code-drawn "rambo" racket held forward with a muzzle flash over the
player, and a splatter-specific serve prompt.
REVIEW on your phone — two opinionated calls, both easy to revert:
1. Dropped the toss in splatter (tap = instant quick-serve, pow 0.62).
Rapid fire, but no apex-timing quality/pierce. Want the toss back as
an option?
2. MG posture is a code-drawn forward racket, not a real body sprite —
good enough, or worth a proper p_mg sprite later?
Also: each rapid serve still auto-consumes a stocked bomb, so bombs
deplete fast now — flag if that feels wrong.
Verified via window.T: melee kills a 1.0m zombie and spares a 4.5m one;
a near-half zombie drifts x 3→1.9 toward you; firing twice leaves 1
stashed ball + a fresh serve (barrage screenshot); extra balls kill and
pierce; free-walk during a horde (x 0→2.7); horde-tap melees without
serving; tennis toss-serve and Phase-4 bomb consume still work; no
console errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Winning your 2nd splatter game (G.splatGames) — or triple-tapping the
umpire in splatter (forceZombies now calls startHorde) — launches a 12s
horde (TUNE.hordeLen). During a horde: the match ball is gone and taps do
nothing; the racket auto-fires pellets straight up-court at TUNE.mgRate
(6/s, ±4° spread, 26 m/s, flat, 1.2s life) and the thumb DRAG steers you
(auto-assist disabled). Zombies pour in at TUNE.hordeRate (2.5/s) from the
far baseline and the sides, ignoring zMax; the speed ramp still applies.
Pellets live in a new zombies.js `pellets` array, kill on contact (always
splatter-quality). splatter() gains a fanfare flag — pellet kills make
gore/piles/points but skip the multi-kill streak so a horde doesn't spam
MASSACRE. splatPoint() gains a quiet flag: during a horde the umpire only
calls a score every >2s (game-win lines always speak); the HUD score keeps
ticking. Horde ends → setupPoint() resumes serving, voice('quiet_dark');
leftover zombies stay for you to mop up. Eaten stays lethal during hordes
(a zombie reaching you ends it and clears the swarm). Pellets render as
2×2 yellow tracers with a ground shadow; serve prompt suppressed while
G.horde. window.T exposes pellets + forceZombies.
NOTE for Oliver: per spec every game won after the 2nd triggers a horde,
and games won by the machine gun mid-horde count too, so play gets very
horde-heavy after game 2 — flag if you want a cooldown or a knob.
Verified via window.T: cheat + 2nd-game both launch the horde (1st does
not); ~29 spawns over exactly 12s; pellets stream (7 in flight) and clear
a lane, building piles/gore; 4 points scored but the umpire speaks once
(rate-limited); horde ends cleanly back to pretoss; eaten still fires
mid-horde; regression ladder green — tennis 11/12, Phase 1 flick-serve,
Phase 2 DOUBLE→TRIPLE, Phase 4 MASSACRE ×4; no console errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Kills now grow 3D-ish gore mounds, not just flat stains. zombies.js gains
a piles array {x,z,size,seed}: a kill within 0.6m of a pile grows it
(size +1, cap 4), else starts a new one (cap 30, oldest shifts out).
Ball boys shave overlapping piles at size -= dt*0.25 — far slower than
stain mopping (dt*1.6) — so net growth wins over a long match. Splatter
particles bumped 14→24, ~1/3 dark and longer-lived (settling gore),
render reads pt.dark for colour.
render.js draws each pile as seeded pixel-art blobs (mulberry32 from
p.seed): N=6+size*5 blobs, offset within size*0.28m, lifted up to
size*0.14m so the pile has visible height, weighted gore colours + ~10%
bone flecks. Blobs are built once and rebuilt only when the pile GROWS
(builtSize guard) — never on the per-frame mop-shrink — and each pile
costs a single proj() call (blob screen positions offset from the base).
Piles draw split far/near of the net like zombies so height occludes
right. window.T exposes render/piles/gore/parts for verification.
Verified via window.T: clustered kills build tall dense mounds while
singles stay small (screenshot), avg render 0.61ms with 10 piles, 0 blob
rebuilds across 120 frames, mop shrinks a size-4 pile at exactly 0.25/s,
no console errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Field data (miss log) showed the dominant unfair miss: the assist parks
you on the landing spot, the ball spends the whole swing window below
minY rising off the bounce at your feet, and becomes hittable one frame
after the window closes. Root fix: the below-ankles rejection now only
applies to a DROPPING ball — rising post-bounce balls can be scooped at
any height (a half-volley). Feet-bounce taps go from ~0 to 19/30 winning
frames; 30 simulated real points now log 50 hits, 0 whiffs.
Also: swing-log snapshots record the same swing-timer value trySwing
tests (removes the one-frame misclassification behind 'unclassified'),
and tapping the scoreboard toggles an on-screen dump of the last 9 log
entries so live situations can be screenshotted from the phone.
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>
- doHit/hitServe in splatter mode lock onto zombies instead of tennis court
depths: your flick picks the lane and depth band, the shot targets the best
match anywhere on the court (including your own side) with a walking lead;
serves auto-track the most advanced shambler
- hidden shortcut: tap/click the umpire three times within 1.6s during tennis
mode to summon the transition immediately
Verified: near-side and far zombies killable, cheat triggers transition,
tennis mode targeting untouched.
Co-Authored-By: Claude Fable 5 <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>
game.js (830 lines) -> 9 modules, native ES modules so still zero build step:
dom canvas handles (imports nothing; keeps cycles harmless)
config TUNE (live-tunable) + structural constants
state all mutable game state (G) + setupPoint/sendBoy
audio sfx synthesis, recorded umpire voice, reverb, speech fallback
input keyboard + touch, exports axes and takeFlickAim()
gameplay ballistics, swings, serve, rules, AI, update loop
render camera, viewport, sprites, crowd, all drawing
panel T tuning UI
main boot, loop, intro, window.T test hooks
Deduped into named constants: bounce tempo (was in 3 places), contact
height band, intercept zone depth, home positions, player bounds, toss
params, shot depths, pose hold time, sprite scale, actor heights. Also
extracted drawShadow/drawBall/expirePose from repeated code.
One intentional import cycle (input <-> gameplay), safe: both sides only
call functions at runtime, never during module evaluation.
Regression-verified in browser sims: point cycle, CPU returns 11/12,
assist contract 8/8, corner-winner gradient intact (0/12 perfect corners
returned, 10/12 center balls returned).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>