Game 30 is both a set and the third chapter boss. pendingHorde is a LATCH: the
set arms it and the next 'over' beat to expire cashes it — and with the boss
outranking the set at that clear, the next beat to expire was the chapter card.
The wave arrived on top of the boss.
The latch is now left alone while a boss fight or a duel is running, so the beat
after the fight cashes it: the set still gets its horde, just not on top of the
thing that outranked it. Verified both halves — ten seconds of boss fight with
the latch armed and no spawn, then the wave arriving once the boss is down.
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>
Viewport, again — the homescreen app was the case that broke it. Watching for a
signal cannot work there: launched standalone, iOS resolves the CSS viewport once
against a provisional box and never re-resolves it, so no resize, no layout
change, nothing for a ResizeObserver to observe. Only a rotation forced the issue.
So the CSS box is now a proposal: visualViewport says what is really visible and
the height gets pinned in px to match, re-read on every fit — plus a short settle
salvo after launch, since that launch announces nothing.
- The countdown skipped the transition, where overT is parked at 9999 to hold the
point flow. It read as RESUMING IN 9999 after the first game you ever win.
- Countdown moved off the HUD line into the open, at a readable size.
- A returned shot is a glancing blow now, not a mirror. Reversing the incoming
line sent every parry straight back up it — into the thrower — so a boss handed
you a free wound per volley. Contact across the racket face sets the angle:
dead centre still goes back at him, which is a placement you earn.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The portrait fit was right, it just ran once and too early: iOS Safari lays the
page out before its toolbars settle, so the box the first fit measured was short
by a strip — and no window resize follows, because the page never scrolls. The
height only came good after a rotate and back, which IS a resize. A
ResizeObserver on the canvas watches the box itself, so every cause is covered
(bars settling, rotation, split view, zoom) without guessing which event or delay
to listen for. It only rewrites the backing store, never the CSS box, so it
cannot feed itself.
Verified both directions: box grows -> logical H 585 -> 671, box back -> 585,
logical aspect matching the box aspect each time.
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>
The 8px of breathing room under it read as padding, not as breathing room.
SAFE.bottom still lifts the rack clear of the home indicator — that gap is
the one that has to be there. The desktop key hint lived in the band that
just disappeared, so it moves to the empty top-right corner.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The run ended on the second wave every time: the gun fired 6 flat rounds a
second, hit one body each, and every shambler soaked one — while hordeGrow
made each wave longer and denser. Doubled the rate and scaled it with the
wave (14/s at one set banked, 21/s at four), gave a round 0.5m of extra
reach, a wider cone, and enough damage to drop a shambler outright. Raised
the spawn rate to match, so surviving a wave still leaves a standing crowd
on your half — that pressure is the point of it.
Also: he no longer carries a second racket at his hip while holding one
forward (the run frames are the racket-less art), the umpire stopped talking
over himself and saying "remain seated" twice, and a timed freeze now shows
what it is counting down to instead of reading as dead controls.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The triangle was mine. Extending the out-area quad to z=60 put it past camZ
(48), and proj() divides by (camZ - z) — so the scale explodes at the camera
plane and goes negative beyond it, folding the quad's far edge back above the
screen. The quad goes back to z=30; the full-frame clear is what covers the
screen, so it never needed extending in the first place.
The bottom still not being green was a second bug, in the previous commit. The
gradient was set on body, but the ROOT element's background is what paints the
viewport, and index.html gives html a background too — so body's never
propagated and painted only its own box, which has no height because the canvas
is position:fixed. It goes on documentElement now, and html gets an explicit
height, because a gradient is laid out in the root's own box and that box
collapses for the same reason.
Verified with the canvas deliberately ending 40px short: root box fills the
viewport, gradient's lower stop is rgb(41,92,45), the canvas's bottom row is
the same, and the seam is invisible.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
That band at the bottom was not a background mismatch — the canvas was
transparent there. render() never cleared: the stands cover the top and the
out-area quad covers the middle, so it looked complete, but the quad only
reached z=30 and everything below where that projects was see-through. The
page showed through it, which is why it read as a slightly duller green.
The frame is now cleared to pal.out before anything draws, oversized by 8px so
a camera shake cannot expose an edge, and the out-area quad runs to z=60 so the
perspective floor reaches the bottom on any aspect. Verified by sampling alpha
down the bottom third: opaque everywhere, one uniform colour, matching the page
gradient behind it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A phone's rounded display corners clip slivers out of the canvas, and the flat
dark body showed through them as a dark notch where the bottom corners curve in.
The page background is a two-stop gradient now: dark down to the top safe-area
inset, where it sits behind the status bar, then the court's out-area colour for
everything below.
That colour is read live from the palette rather than hardcoded, because it
lerps between the tennis and splatter courts — bright green to dark olive — so
a fixed hex would only match in one mode. Written in resolved pixels rather
than env() so the declaration can't be invalid and drop the background
entirely, and only assigned when the string actually changes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds two sections to HANDOFF. "How to work on this" records the meta-lesson:
every single "this feels odd" note Oliver gave turned out to be a real defect
rather than a tuning preference — seven for seven — so the rule is to measure
before touching a tuning value. It also records that screenshots are not a
verification tool at phone scale (small pixel art is unreadable, and a silent
sprite fallback renders a wiring bug as working code), that drawImage
instrumentation is what settles those, and that a scripted str.replace which
matches nothing is a silent no-op that must be asserted.
"OPEN — leftovers" lists the ten known gaps in priority order: RES 4 unverified
under load on a real phone, the horde melee fix unplayed, the player idle frames
upscaled rather than re-sliced, asymmetric boss wound stages, only three boss
designs, landscape drifting untended, cards without an entrance, the deliberate
chapter-skip, parked step 6, and one audio line to confirm.
Also corrects the module map: there are three runtime-only import cycles now,
not two — state<->zombies joined when sendBoy started reading gore.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The remaining gap was the home-indicator inset being reserved on the canvas
element. Unlike the status bar that is a slim translucent bar, not opaque UI,
so the canvas now runs to the physical bottom edge and the court art fills it —
only the weapon rack and the draw meter lift clear of it, through SAFE.bottom.
The top keeps its element inset, since the scoreboard genuinely has to clear
the status bar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Insetting only the HUD while the canvas stayed full-bleed wasn't what was
asked for. The canvas itself is position:fixed and inset by
env(safe-area-inset-*) now, so nothing renders under the notch, the status bar
or the home indicator, and render.js no longer offsets the HUD as well — doing
both would subtract the notch twice.
The bottom strip was a second bug: the canvas was sized by computing a scale
from innerWidth/innerHeight and setting explicit pixels, so it was laid out
against whatever innerHeight reported at load, before Safari's toolbars had
settled, and stayed short of the bottom forever after. CSS stretches it now
(100vw x 100dvh minus the insets) and fitViewport derives the logical grid from
the box it was actually given, so the aspect always matches exactly — no
letterbox, no distortion.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
viewport-fit=cover was already on, so the canvas was full-bleed and the iPhone
status bar landed directly on the scoreboard. Keeping the canvas full-bleed —
the stands running to the screen edges is the look — and moving the HUD
instead: a hidden probe div carries env(safe-area-inset-*), which env() only
exposes to CSS, render.js converts it to logical canvas px at the same scale
fitViewport just applied, and the scoreboard shifts down while the rack and the
draw meter lift up. Body height also goes to 100dvh so iOS's dynamic toolbars
stop leaving a strip unused at the bottom.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The portrait canvas is adaptive now. A fixed 270x480 is 9:16 while a modern
iPhone is taller, so width bound the fit and ~150 CSS px of screen went unused
at the bottom. Height is derived from the device aspect, and TUNE.horizon rides
with it — proj() places the court in absolute pixels, so leaving horizon fixed
just grew a dead green band between the court and the rack instead of moving
the court down. RES 3 to 4 for the extra pixels: an iPhone gives ~1125 device
px across and we were rendering 810. That is the first knob to turn back down
if the phone stutters.
The swish cooldown scales with how swarmed you are — 1.00s at one body, 0.47 at
four, 0.22 from fourteen. Oliver died on every game-20 horde because the wave
grows with hordeGrow while the panic button stayed a flat second; he chose
scaling the melee over shrinking the horde. The first version measured the
crowd after meleeSwipe had already killed it, so it counted survivors and
barely moved.
Winning a duel while wounded lost the weapon popup entirely: showModal never
clobbers an open modal, so the heal modal opened first and the tin's was
dropped. One popup carries both now.
And the mop boy stays off the court unless a stain actually passes the test
mopRound uses to pick a target. He was walking on, finding nothing reachable,
and walking straight off again.
Tennis untouched: 50 hits / 0 whiffs. 45-game soak clean, all four chapters.
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>
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>
Oliver's call: duels have to come round on their own so there is always a shot
at a weapon. So the game-count schedule isn't the bug — it's attached to the
wrong event. Item 5 now moves it from delivering tins to calling duels: same
cadence, same umpire-restores-order framing, but the weapon has to be earned.
Fires off proceedAfterClear(), which is the one moment the court is guaranteed
empty (the wave was just cleared by hand and nothing has respawned). Clearing
is already mandatory before play advances, so the schedule can't stall — a
wall-clock timer would need its own wait-for-a-safe-moment logic and could fire
mid-swarm. The existing opportunistic trigger stays as a bonus path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fourteen items from Oliver's phone-test list, written so a fresh session can
execute them. Every root cause was verified against the code rather than
guessed; the two "bugs" both turned out to be real and both have a named line:
wCap 3 caps the stock, and weapons leak in through a SECOND delivery path
(splatPoint's tinDue -> startChangeover) that has nothing to do with the duel.
The serif complaint is also a bug, not taste: only 4 of 15 canvas font strings
ask for Silkscreen, the rest say bare "monospace" and land on Courier.
Ordered so the three foundations come first — the modal system (five separate
popup requirements need the same input-gated pause, which does not exist yet),
the UI colour constants, and the font fix — since the later items draw through
all three.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reads as something leaving your hand and receding rather than as a label on the
zombie, which is what the perspective shrink was already saying. The arrowhead
stays fully opaque, so the target is still unmistakably marked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The chevrons were stepped along the SCREEN line at a fixed size, so they read as
a flat sticker over a 3D court. They now step through world space and take their
size from their own projection scale — near ones roughly twice the far ones —
and they fade IN toward the target so the eye is walked up the lane instead of
being handed a uniform stripe. The arrowhead scales with the target's own depth
too. (proj is a true projective map, so the screen direction stays constant
along the line; only size and alpha needed to vary.)
Hard shots now fly hard: serve pace is weighted onto quality (19 base + 22×q,
so 21 -> 41 m/s) instead of 21 -> 28. Two things had to give way for it.
Serve topspin was a fixed 1 + 0.35q, which meant a fast flat ball could not
both clear the tape and land on a target, so the net-loft loop throttled every
smash back to walking pace — measured 21.8 m/s whether you drew 60px or 95px.
TUNE.serveSpin 1.3 lets it leave flat and DIVE, which is what a smash is.
And the loft loop's margin was doHit's quality cushion, which taxed exactly the
shots meant to hurt. It's now sized to the real error it exists to cover: the
sim samples the net plane once a frame, so a diving ball can test below the tape
when the closed form clears it. Margin = descent rate × the dt clamp. A flat
drive pays almost nothing, a lob pays what it must.
Measured, target 20m out: 24.7 / 32.2 / 41 m/s for weak / mid / full draw. At
the net it's 9.6 / 11.2 / 11.6 — still the slow floaty lob, as it should be.
Ball-vs-shambler contact gets a crude sweep (radius + half a frame's travel),
because a 41 m/s ball steps 0.7m per frame at 60fps and 1.6m at main.js's dt
clamp, both wider than a shambler. Verified the full smash connects at both.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Camera dynamics off (aimCamY/aimCamZ 0) — the knobs stay, so either direction
is a value away in the T panel, but the default holds still now.
The aim graphics carry the read instead. A chevron trail runs down the lane and
plants an arrowhead on the target: the chevrons multiply and fatten as the draw
builds, so the trail reads as pressure rather than as a static dashed line, and
the direction is unmistakable now that the angle actually selects a lane.
The new part is the draw meter on the apron in front of your feet, because the
kill threshold was the one rule with no picture: you could hear the click and
see the colour flip, but never see the line COMING. Now a white notch marks it,
the fill runs cold below and hot past it, and a full draw flashes. Anchored at
the feet rather than over the head — up there it sat right in the lane with the
chevrons running through it — and clamped so advancing up-court can't push it
into the rack.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The aim camera now drops and pushes IN while you draw instead of lifting and
leaning back — you crouch into the shot. Both knobs just flip sign, so the old
behaviour is a sign change away in the T panel.
And the reason the pull's angle felt like it did nothing: aimCone 0.45 meant a
lane half-width of ±10 m at the far baseline, which is the entire court. Every
downward pull locked the same body, so the indicator's angle was decoration.
At 0.10 (±2.9 m out there, ±0.9 m at your feet) the angle picks a lane for
real — swept in 4° steps against three shamblers 18 m out, each one owns a
~12° window and past ±20° the ball flies into an empty lane. Thumb-sized, but
you have to point.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things from the phone test.
The drag meant two different things depending on invisible state: aiming from
pretoss, but walking the player around while the last ball was still in the
air. That "mode between the hits" read as a bug, so it's gone — canAimServe now
covers every phase of splatter, and firing over a live ball is allowed again
(the old one flies on as a ballistic clone). That was the mid-rally re-toss
that used to be the spam vector, and it's safe now because the brake moved into
the pull: a rhythm of lazy 30px pulls fires 100 shots in 30s and kills NOTHING
(the court fills up instead), while 85 committed full pulls kill 11.
hitServe never had doHit's net-clearance loop, so every shot at a shambler
hugging the tape died in the net — not balance, a missing loop. Both paths now
trade pace for arc until the ball is genuinely over, and the ceiling goes 8 ->
12 tries because a target right behind the net needs a near-vertical drop
(measured: clears the tape by 0.20m and connects). The trade is real: the loft
arrives slower and the walk-lead was computed for the original pace, so a lob
is honestly the less reliable shot.
And the camera now rises and leans back a little while the slingshot is drawn.
That steeper look is what makes the gap between the net and the bodies behind
it readable — the shot that used to be blind. Eased once per frame before
anything projects, since one sheared frame would be visible.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ball economy made it harder in the wrong way: waiting for a ballboy is
friction, not difficulty, and walking with the finger was one gesture too many
for anyone to discover. Both are out — a spent ball hands you a fresh one
again, and the thumb only ever does the slingshot.
The brake moves into the gesture instead. The pull's LENGTH is now the shot's
power and nothing else: under TUNE.zSplatQ it merely staggers, at full draw
it's quality 1.0 with the PERFECT flash and hitstop. A lazy rhythm of short
pulls can't kill anything, so tempo stops being a strategy.
The pull's DIRECTION is a ray, not a landing point, which is the ergonomic fix
for having to drag off the bottom of the phone: distance to the target no
longer costs drag length. The first shambler standing in the lane eats the
ball, whether it's 5 or 21 metres away, and the cone widens with range so
distant lanes stay pickable. Full power now sits at 95px of travel.
Also gone: the free predictive auto-lock on a directionless aim (zombieTarget
returns null with no direction, and a bare hold no longer fires at all). Melee
goes back to cleaving the cluster — it's the panic button — with the 1s
cooldown left in as the brake. The reticle reads power throughout: cool blue
only staggers, hot red kills, the drawn band grows behind you, and the pull
clicks the moment it crosses the kill threshold.
Verified by simulated touch gestures: 10px cancels, 35px = 0.31 (stagger),
60px = 0.60, 95px = 1.00; the same 60px pull reaches shamblers at 5m, 21m and
diagonally; 22 serves in 30s, so the pace is back.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The slingshot serve didn't fix the gameplay because four things were free at
once: pure-hold auto-target with predictive lead, a hardcoded 0.62 serve
quality, infinite ammo with an instant mid-rally re-toss, and a melee swish on
every single tap that cleaved the whole cluster. Perfecting the pretoss/toss
rhythm beat the game; no skill required.
The ball is scarce now. A spent ball LIES where it died (G.loose, ringed on
court) and there is no serve until it's back in your hand: walk it down
(instant, and the only reason to leave the baseline), let a boy scoop it, wait
out TUNE.looseMax, or watch a shambler swallow it — the one real punishment.
Fire rate is a positioning decision instead of a thumb-tempo one.
Also: the far backstop rebounds a ball with pace left, still lethal on the way
home and volleyable (trySwing lets P re-hit their own ball in splatter); melee
drops to nearest-one-only on a 1s cooldown; and the mid-rally re-toss is gone.
With no ball in hand the thumb-drag steers your legs instead of the reticle —
without that, walking your ball down is impossible on touch.
TUNE.looseMax is the fire-rate knob. Measured: ~4s per serve standing still,
~0.4s if you go and get it. 600 taps in 10s now yield exactly one serve.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The touch serve drag now inverts: pull toward yourself to launch up-court.
The first pull past the deadzone starts the real toss animation (serveToss),
dragServe fires straight from the toss phase without banishing its own fresh
toss, and a held aim keeps the toss airborne instead of dropping. Render shows
an Angry-Birds dotted trajectory racket->target (red = zombie locked). Two-stage
tap serve, mid-rally re-toss/banish, and keyboard serve+arrow-aim unchanged.
Co-Authored-By: Claude Opus 4.8 <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>
- Bleed dialed way down — thin, steady trickles (a fine spurt from the neck
stump) instead of the gushing cloud; tighter spread, shorter-lived, capped low.
- Rapid serve is easier to reach: a plain tap MID-RALLY now fires at the
auto-target too (not only flick/drag/hold), so you can pump the screen while
the last ball's still up. Pretoss keeps its toss ritual; the duel stays tennis.
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>
The ad strip sat 12px above the far baseline, but the out-area lawn reaches z=-6, whose projected edge is ~15.5px above the baseline in portrait (camF is larger there) — so the lawn painted over the bottom of the boards. Pinning the strip's bottom to proj(0,0,-6).y clears them in both orientations.
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>
- Personal lane offset (homeOff ±0.8m) + a per-zombie shamble wobble so
on your half they fan around you instead of stacking into one column.
- Eaten check weights depth by 0.75 (z is foreshortened in the projection),
so a head-on shambler kills you when it LOOKS on top of you — fixes
"they touch me and I don't die." Lateral reach unchanged.
- Revert aiFlub 0.14 -> 0.09 (undo the CPU weakening).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>