PERFECT fired on almost every serve, and it was not a threshold that had drifted
— it was the wrong test entirely. pullFrac CLAMPS at pullFull, so pullQuality is
exactly 1.0 for any pull of 130 px or more, and 130 px is an ordinary comfortable
thumb drag. "quality > 0.999" therefore meant "you pulled far enough", which is
not an achievement and is why it read as noise.
So hitServe takes `perfect` as its own argument now. The toss ritual keeps
earning it the honest way, on the 45 ms perfectMs window. The slingshot earns it
by STOPPING in a band at the top of the draw (pullFull..pullFull*perfectBand,
130-166 px) — a target you can miss, rather than a ceiling you cannot. The pull
already clicks when it crosses the splatter threshold; it clicks brighter when it
enters the band and goes silent when you pull past, so the window is learnable by
feel instead of being invisible skill.
And the word rotates: PERFECT, TEXTBOOK, IMMACULATE, OH LOVELY, UNPLAYABLE, SHOT
OF THE DAY. Cycled rather than random — the same word twice running is what makes
one read as a stuck HUD element rather than as praise. The umpire has still never
acknowledged the massacre, so a clean shot gets the line it would get at Wimbledon.
Also: the card band grew 72 -> 80 px, because the second sub line ended 4 px off
the bottom edge and looked like it had fallen out. And the quiet face floats as
much as the loud one does, so it gets the same 1 px shadow — the control hints,
the gesture-demo labels, the card subs, the modal body and prompt, the death
summary lines. The scoreboard and the rack keep theirs off: both already sit on
dark panels, and a shadow under 6 px type on a flat fill only smears it.
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>
TUNE.beamBand flips between the two looks live from the T panel. 1, the new
default, draws the laser the way the BALL's aim trajectory is drawn: a path down
the lane whose width comes from each point's own projection scale, so it narrows
into the distance like everything else on the court, with a shadow on the lawn to
pin it there. 0 is the solid widening cone.
The cone was a true projection, which is why it was not obviously wrong to me --
but it GROWS in world width, so on screen it opens out while the court recedes,
and the eye reads that as a shape stuck to the screen rather than a plane lying
in the world.
The kill follows whichever look is on, in both fireBeam and drawBeamCuts.
Otherwise the comparison would be a lie: a narrow band that deletes a cone-wide
crowd tells you nothing about how the narrow band plays. Measured down one lane,
the cone takes 9 of 9 and the band 5 of 9.
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>
Lower alpha through the whole fill (0.34 -> 0.19 at the strings, falling with
depth) and the core is a LIT BLUE rather than near-white, which is what was
washing it out. The stroked edges come down and turn blue with it.
Worth recording, because it will come up again: the cone's opacity and how well
you can tell above-the-plane from below-the-plane are the same dial. The plane is
flat -- a body above it is drawn clean on top, a body below is seen through it --
so the distinction just IS the cone's alpha. Fainter cone and sharper split pull
against each other; the fainter cone wins and the cut line carries the signal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The blinking was drawZombie validating its frame count against the LAST frame
only: a set with (say) z_h2 missing but z_h4 present kept N = 4, the walk cycle
stepped onto a name that had never loaded, and drawStatic silently drew nothing.
The body vanished for that frame of the cycle and came back on the next one,
forever. One cached 404 -- this project's oldest trap -- is enough to cause it.
It holds frame one now: a body that stops animating is a bug you can see, one
that flickers in and out reads as the game breaking. SPRV bumped to flush
whatever his phone has cached.
And no ctx.filter anywhere. The cut line was briefly the sprite redrawn clipped
to a band and flattened by a canvas filter, which put it exactly on the
silhouette -- and on the iPhone the line did not draw at all. Unsupported or
leaking past its restore, a rendering feature that fails silently on the target
device is not worth a pixel of polish. It is a plain bar again, brighter, and it
crosses the odd gap between the legs, which is the trade.
The cone gets the reference's look: a horizontal gradient across the width with a
near-white core down the centre line falling to blue at the edges, plus the depth
falloff along its length, plus stroked outer edges. Canvas has no 2D gradient, so
it is filled in eight slices each carrying its own across-gradient -- enough that
the seams vanish, which is what the old nested wedges got wrong.
Test mode: barrels gone (the rack already holds everything they would give you)
and the crowd raised to 16 bodies at a spawn every 0.9s, so it does not run dry
between kills.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The beam is a real plane now, holding TUNE.beamY (1.15 m) all the way out instead
of sloping to the ground. That one change is what makes the rest possible: sloping,
it lay flat on the grass across the whole far half, so there was nothing to cut
exactly where the crowd stands.
One flat translucent triangle, apex at the player's CHEST, opening out down the
court with the alpha falling away. It was four nested wedges composited 'lighter'
and that layering WAS the banding -- additive light blowing out to white where the
layers overlapped. Flat alpha also means a body under the plane is tinted rather
than brightened, which is what lets the cut read at all.
Bodies are sliced by it: the part above the plane is redrawn clipped so it sits on
top, the part below stays tinted underneath, and it happens while AIMING as well as
on the shot. Measured, the plane takes a shambler at 64% of its height, a brute at
43% and the roller man at 36% -- bigger bodies visibly take it lower.
The cut line follows the SPRITE rather than a rectangle. A fillRect spanned the
body's bounding width, so it crossed the gaps between the legs and under a held-out
arm and read as a bar lying behind him; the line is the sprite itself, redrawn
clipped to a thin band and flattened to one colour by ctx.filter. It thins with
distance on s squared, because this camera's scale only spans about 1.5x across the
court and a linear falloff was invisible.
Aiming a filter at a specific colour turned out to be a measurement, not a guess:
brightness(0) invert(1) goes to WHITE, and sepia adds almost no chroma to white, so
the first chain rendered a grey. Leaving a mid grey for sepia to tint lands on
(32,154,255) against the laser icon's (29,153,232). The probe fills a rect and reads
the pixel back.
The pass is bounded to the eight nearest bodies above 14 px, because it draws each
one twice more through a filter and runs every frame you are aiming. 0.27 ms/frame
with 20 bodies on desktop, against a 16.7 ms budget.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The mg racket is gone, not moved. It was a procedural racket drawn over a sprite
that already holds one: straight up-court it projected onto the player's back as
a floating oval, and offset to the side it just stuck out.
The laser's far edge was pinned to the middle of the ad strip two commits ago and
nothing appeared to change, twice. The geometry was always right -- each wedge
layer faded to alpha 0 along its length, so the beam went invisible before it
reached its own far corners and the VISIBLE end sat well short of the pinned one.
The far stop is lit now, so the two are the same line. Verified by recomputing
the projection: far corners at y 113.8, the ads' middle, against 110.2 unpinned.
It also falls off with distance now, brightest at the strings and thinning down
the court -- but never to zero, which is the trap above.
Fixes a third instance of the empties-under-the-iterator bug: a rocket blast can
bank the last kill of a game, and the duel or chapter boss that follows clears
pellets[] mid-loop. shots, pellets, extras and the body loop all carry the guard.
Also records that two consecutive render() calls do not produce the same frame --
render mutates camera smoothing as a side effect -- so frame-diffing to find where
something was drawn measures the camera drift instead. It claimed the beam reached
16 px above where its own geometry could go.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A burnt body does not burst. Fire kills by cooking, so there is nothing left to
spray: a body that dies alight -- flamethrower or flame ball -- leaves dark grey
ash instead of blood. Same decal, mound and particles, recoloured and smaller,
with a puff of smoke and a dull thud instead of the wet splat and the headshot
crack. The flame BALL has to mark itself, because that kill lands before the body
ever catches.
The flamethrower is a lighter, not a gun: no impact damage at all, it pierces,
and it only IGNITES -- the burn does the killing, so a lit crowd walks a few
steps and goes down together. 26 m of reach against a 22.8 m court, so it
crosses to the far baseline. The guard matters: hp -= 0 fell straight through to
killZombie, which made the cone delete the crowd on contact.
Every burst weapon had no progress bar and the cause was the spend. Arming spent
the tin at once, so the rack entry vanished -- and the bar draws on the slot
holding G.gunId, which no longer existed. The tin is spent when the burst ENDS
now, so icon, count and bar are all there while it runs, and every path that
ends a burst routes through endBurst or it would hand out free ammo.
Auto-fire movement again: the previous fix read moving as mx||mz||assist, and
finger-steering zeroes mx/mz, so sliding left and right still counted as standing
still. And the oval on his back was the mg racket -- its head projected almost
exactly onto the sprite; offset by facing it reads as held out beside him.
The laser's far edge is pinned in SCREEN space to the middle of the ad strip
rather than running out to beamLen on the ground, which landed on their top line.
beamLen goes back to being the kill range instead of a number tuned to one camera.
Beacon thumps, rocket swooshes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The auto-fire jog was one guard. gameplay skipped the pose update in mgSteer mode,
so the pose stuck on 'idle' -- and render covered for it by faking a run cycle off
G.idleT, which animated whether he was moving or not. That fake is what read as
"this hectic run animation... odd all the time". The pose follows MOVEMENT in every
mode now and the fake is deleted, so auto-fire, the horde and the melee racket all
use the ordinary run animation and standing still is standing still. The comment
that justified the fake -- two rackets when idle -- was solving a smaller problem
than it caused; at sprite scale the overlap does not read.
The laser, second pass, all of it "it must read as light he fired":
- it draws AFTER the net and every body. It was drawn before the far-side pass,
so the net and the crowd painted over it, which is exactly why it looked like
it was lying on the floor and sitting under the net.
- beamLen 26 -> 34: at 26 it stopped just past the far baseline and read as cut
off in mid-court. It carries into the courtside ads now.
- beamW0 0.35 -> 0.06, a POINT at the strings. At 0.35 it left the racket as a
0.7 m bar hanging in mid-air instead of something emitted from it.
- it DELETES ordinary bodies, hp irrelevant. A plane of light that left a roller
man standing made it a worse ball, not a weapon. A boss is the exception and
takes dmgBeam 8 -- severe, survivable.
- four nested wedges composited 'lighter' (violet bloom, blue, cyan, white core),
each fading along its length. One quad with a gradient was a coloured shape.
Rocket: speed 17 -> 38, and a real jet plume laid tight ON the flight line rather
than scattered sparks, which looked like a firework.
Beacon: a finer one-pixel thread and a much brighter ball, so the BALL is the
bright thing and the trail only says where it has been.
And the bug in the middle of that: firePellet stamps a whitelist of fields onto
the round, and jet/trailR went onto the WEAPONS rows without being added to the
list -- so the rocket flew with no plume and nothing errored. Any new pellet field
has to be added in both places.
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>
Oliver's playtest of the barrel weapons. Four notes, all four real defects.
"The bigger guys were way too easy to kill at the beginning." A bug, and an old
one. The ball-vs-body chain handled boss, g/m, s, f and a/b, and everything NOT
named in it fell through to "splat-quality kills outright" -- so the STEWARD has
died to one serve since the day he shipped with stewardHp 5 and a comment calling
it a brute's worth of hp, and the three new bodies inherited that the moment they
existed. Pellets and melee always read hp; only the ball skipped it. One generic
hpMax > 1 branch now, and measured after: shambler 2, steward 5, brute 5, ball
boy 3, bouncer 5, roller man 7 -- each exactly its hp.
"I have seen all of them by round 10." The new three move 3/8/15 -> 5/12/20. The
original four keep their gates: that curve was already tuned through game 12.
"The laser fired only when I tapped, that was confusing." The whole tap-per-round
kind is gone. A barrel weapon is now either the normal ball gesture made
spectacular or something that fires itself -- nothing waits for a tap.
- laser is a serve, and the RAY is the weapon: no ball anywhere in it. The
preview IS the wedge, it leaves the RACKET rather than lying on the grass, the
release erases the plane along its own path behind a bright front, and the
edges are hairlines instead of the heaviest lines on the court.
- beacon and rocket are LOBBED bursts -- `arc` turns a flat pellet into a thrown
one, solved onto the nearest body ahead, and landing is itself a hit so a
rocket that misses still goes off where it lands. The beacon changes colour
every ball and drags that colour behind it.
- flamer got "way more pronounced": 30 rounds/s, a ~10 m cone, embers.
"I got the laser almost every time." A flat 1-in-4 over four items does not feel
uniform. BARREL_WEAPONS carries weight and a `from` gate now, never repeats twice
running, and the beacon is what you meet first. Measured: laser 16-28%, rocket
unlocked at game 12 so it is genuinely a late find.
And the barrel keeps its pink halo -- that was always procedural, painted by
drawBarrel, and never went near the chroma key. Three rings and a pool on the
lawn now, because one flat disc read as a smudge.
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>
A chapter boss had no AI at all: it matched no branch in the variant chain and
fell through to the crowd's aimless wobble, so "the boss AI owns its x" was a
comment describing code that did not exist. Movement is now data on the BOSS_ART
row -- pace, shadow, surge, charge, bounce -- and bossAI is the one place it
happens. Every behaviour field is optional, so a row carrying only art behaves
exactly as a boss did before.
Three earlier drafts of those styles failed the same way: written as "walk toward
the player's x", which in splatter means arrive in four seconds and stand still,
because the player is rooted in pretoss for nearly all of a boss fight. Every
style now moves under its own power and only biases toward him. Measured over a
simulated minute each: 50-136 m travelled, none static.
Three new designs -- THE HEAD COACH (a 5-shot drill 0.14s apart), THE PHYSIO
(charges, and withdraws less than it charges, so it reaches you at 38s and that
is the fight's clock), THE SPONSOR MASCOT (gap 0, so the whole wall of five
leaves in ONE frame and is dodged rather than parried). Harder to kill: 16 -> 20
hp, 9 -> 10 per chapter, plus per-design multipliers.
Three new bodies: the ball boy (3 hp, 2 skins, from game 3), the bouncer (6/3,
game 8) and the roller man (9/4, game 15), each with a 4-frame walk and a drawn
two-frame lurch for every wound stage.
Three wound rules became one. The brute had a hardcoded stage array, everything
else a single skin behind 'abd'.includes(...), and the boss a fraction of hpMax.
The boss's rule reproduces the other two exactly -- verified: the brute still
draws z_gw/gw2/gw3/gw4 at hp 4/3/2/1 -- and keeps working when hp is not
one-per-skin, which all three new bodies need. So a multi-hit body is now a row
in config.ZWOUND and no code. bleed() reads the same call, which also gave the
steward the trickle his wound skin had always implied.
Fixes a real crash on the way past: killPlayer empties shots[] while the shots
loop is mid-iteration, so every remaining index read undefined and the frame
threw. The mascot's five-at-once volley makes it easy to hit; the shipped 3-shot
volley could always have hit it.
_regen.py gains two style blocks and a check tier. One constant used to say
"keep every colour exactly as the reference" -- right for a remaster, and it
fights every word of the subject otherwise. newart is for a new character, state
for the same character wounded or mid-throw; using newart for the wound sheets
licensed a repaint and got one, five came back with living tan skin. And the
detail gate ratchets against the file it replaces, so check: any exists for
re-rolling already-fine art to fix something the gate cannot see, such as colour.
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>
Every roll drifts the palette and nothing measured it: a white headband on six
frames, pale grey shorts on the toss twice, brick-red shirts and brown shoes on
the swing all sailed past --tries. check: "same"/"loose" skips colour_match, and
colour_match is a global mean/sd fit far too weak to pull brick-red back to red
— a nearest-colour palette lock is worse still, it drags the duller reds into
the racket's browns.
So _regen.py measures three numbers and a batch opts in with palette_ref:
redness (r minus mean(g,b) over saturated warm pixels — brightness alone cannot
tell brick-red from red), shorts brightness (charcoal 61 vs the drift's 83), and
the near-white fraction that is the shoes and socks (brown drops it to 0.9%,
pale shorts push it to 7.9%, correct frames sit at 2.8%). Frames known to be
right cluster tightly on all three; every drifted frame fell outside on at least
one. It rejected five bad rolls in a row, then passed a good one.
The swing also kept re-inheriting its own drift: ref_sheet is built from
[lead] + the sprites being replaced, so it was showing the generator the very
brick-red frame it was about to overwrite. Copying a canonical frame over the
targets first breaks the loop — it passed on the next roll.
Swing is now a real backswing and follow-through (silhouettes differ by 1.0,
racket low behind vs up over the shoulder), both at full standing height, and
the toss and smash wear charcoal shorts again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The "smaller when swinging or tossing" was an anchor bug, not art. _fine.py
scales a whole sheet so the --anchor frame is 224 px, and drawChar draws every
sprite at img.height/SPRITE_H — so anchoring opponent_play on the TOSS, with
arm and ball above the head, made 224 px cover the raised arm and shrank every
body on that sheet. o_swing1 was 162 px against a 224 px standing pose. Each
opponent batch now leads with a standing `ref` frame, which _fine.py anchors on
and _regen.py discards. Correctly anchored the toss is 278 px and the man is
full size; the swing is 221.
The receiver no longer slides 0.12 m side to side while waiting on a serve —
sliding a sprite sideways reads as skating, and the split-step swap to the run
frames (removed earlier) had already been the other half of that.
The idle bounce ball had one hardcoded offset for both men. They reach to
opposite sides of the screen — the player is drawn from behind, the opponent
from the front — so the opponent's ball sat out by his racket. dx and apex are
per-man now, and o_bounce/o_bounce2 were redrawn with a patting hand to put it
under.
Also: the opponent wears a RED headband, and a prompt of mine that said "white"
had quietly repainted it on six frames.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
zombie-mode: 5.9 MB of 24-bit WAV down to 287 KB. Its master is 982597 frames
at 48 kHz, so loopEnd is the un-round 20.47077 — the decoder hands back
20.4787, and those 7.9 ms of AAC padding would tick once a loop. Nothing in
assets/audio/ is a multi-megabyte WAV any more.
o_run1/o_run2 had no racket in either hand. The cause was the batch prompt:
opponent_run never said "racket", and its reference sheet was built from the
racket-less frames it was replacing, so there was nothing to copy one from. It
is a ref_only batch off o_idle1 now — same man, front-facing, racket in his
right hand — with the subject spelling out that he still carries it mid-stride.
The stride reads as well as the old one did.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8.3 MB of WAV down to 768 KB, on a file that downloads on every page load
before the player has decided to play anything.
loopBed takes the full filename now, because the beds are a mix of .wav and
.m4a — fadeBed's key has to match it exactly, extension included, or a duck
silently no-ops.
It also takes an explicit loopEnd, and the theme passes 48. AAC pads its last
packet, so Chrome decodes this 48.000s master to a 48.0174s buffer: 17ms of
silence spliced in at every seam, once a loop, on the title screen. Trimming
the seam is what keeps the loop seamless.
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>
Oliver, after playing the four-stage version on the phone: idle, then the racket
dropping on the pull, then the drive on release — "that's all, forget all the
others". At thumb speed the middle stages are never read, so they are deleted
rather than kept around: p_pull is the wind-up, p_smash is the drive, and both
come out of the photo-referenced sheet, so the whole serve is one drawing style.
The dead batches and the arm_left guard go with them. assets/_pose_ref_serve.png
is kept as the recipe: his photo sequence over our own sprites, which is what
turned a fifteen-generation prose argument into one generation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The racket kept changing hands because the art disagreed with itself: p_idle,
p_bounce and p_toss held it in his LEFT hand while p_smash and p_swing used his
right, so every new frame copied whichever reference it was handed. The idle, the
bounce pair, the toss and the four wind-up frames are all regenerated with the
side named — drawn from behind, his right hand is the right-hand side of the
image, and only the racket HEAD crosses behind his back.
What separates the four wind-up stages is how far the racket has fallen, and that
needed the racket head pinned to a body landmark per frame — above his head,
beside his ear, between his shoulder blades, at the back of his thighs. Described
as "further into it each time" it drew four near-identical poses twice running.
They are wired late in the pull, Oliver's call: frame 1 owns the first half and
the three coiled frames are packed into the top half.
assets/_derive.py is deleted. Fixing generated art by mangling it afterwards is
always worse than regenerating; three guards in _regen.py turn the bad rolls that
used to install silently into automatic re-rolls — a portrait reference canvas
(the generator answered with a 2x2 grid), a non-magenta background (a green sheet
keyed away four racket heads), and a mirrored frame.
The bounce pair is one drawing from one sheet now, differing only in the free
hand, so BOUNCE_IDLE moved to his left side and was re-measured off the sprite.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Oliver, off the file browser: "the racket changes the hand in 3 and 4". It did —
the generator drew frames 1-2 right-handed and 3-4 left-handed. Both mirrored into
line with the rest of the serve (a back-view figure mirrors for free), and the
batch prompt now names the side, since the generator will not hold a prop in one
hand across a sheet unless told to.
The convention the rest of the art already uses: racket on the LEFT-HAND SIDE OF
THE IMAGE, free arm on the RIGHT — the side the game drops the bounced ball and
tosses the served one. p_smash keeps the racket on the right: that is the frame
where it has come over the top to meet the ball it is hitting.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four new frames (p_pull1..4) turn the slingshot drag into the serve's wind-up:
racket above the head, behind the head, behind the back, fully coiled, picked off
pullFrac — the same 0..1 that sets the shot's power — so the gesture and the man
are one movement and the release snaps to the smash.
The two bounce frames are one drawing now. p_bounce2 is p_bounce with the free
arm sheared up 12px and nothing else touched, so the swap cannot flicker, and it
follows the ball instead of its rising phase; BOUNCE_IDLE was re-measured off the
sprite so the ball tops out under the palm. p_toss had the tossed ball drawn into
it — cut, canvas height kept, because the game scales him off img.height.
The runner: hip centres sat -2.0/+7.1/-6.0/-0.1 px off the sprite centre, a 13px
lateral jump at 12 frames a second, and Oliver's diagnosis was right that all four
frames leaned the same way and lifted the same leg. Frames 3 and 4 mirrored, all
four padded to one width with the hip on the centre line.
assets/_derive.py holds the three derivations, since "the same drawing with one
small difference" is the one thing the generator cannot draw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Oliver spotted it before testing: a whole-frame mirror swaps the DRAGGED POLE
into the other hand as well as the legs. True for the one wound stage that still
has an arm holding something, so that stage gets a leg-band mirror about the hip
centre instead — legs swap, pole and stump stay put. The other three stages have
no arms and no props, so a full mirror is right for them.
The honest cost: a leg mirror leaves both feet in the same two places, so that
one frame reads as a leg swap rather than a stride. Written up with the fix that
worked for the shamblers (a passing pose, feet together) and the measurement that
tells them apart.
HANDOFF gets the session: what changed, the two lessons that cost the most (the
prompt sets the art resolution; the reference sheet is what the generator
copies), the rule that anything which must stay identical between two frames
belongs in code, and the four known limitations ranked for whoever picks it up.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The prompt is what sets how fine the drawing is, and this session set it wrong
for eleven sheets before Oliver named it. That, plus what _regen.py and _fine.py
are for and the checks that stop a bad sheet reaching the repo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Discussion-only session, no code. HANDOFF gets a new LATER section holding the
decisions: paid $2.99 both stores with no ads, a web demo capped at the end of
chapter 1, the CHALLENGE (Hawk-Eye as a free earned continue) and the
short-form video campaign — plus what must NOT be built, because selling
weapons or adding a meta currency would fight settled design decisions.
OPEN gains item 11: the ball machine's squeak, burning zombies, the machine-gun
burst and the boss sound set all need a source chosen before implementing.
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>