Commit Graph
196 Commits
Author SHA1 Message Date
wuwiandClaude Opus 5 c5edc94b73 Redraw the backdrop and cool the menus down
The court art had three faults: a hard-edged pale panel sitting on the near
half, mown stripes that stopped and restarted where the court began, and the
blood pool stranded behind the baseline where a portrait phone cropped it off.
Redrawn with the stripes running unbroken the full depth and the stain out on
the court where it can be seen.

The picture also read as zoomed in, which was not the art but the crop: at 4:6
`cover` was throwing away a third of the width on a phone. The sky is extended
to 1:2 instead, so the whole arena survives the crop.

Menu palette rotated off olive towards a cool desaturated blue-green with much
quieter borders, and the boxed BEST RUN / WORLD RANK bar is gone from the
splash — it sat on the best part of the art to say what the two buttons under
it already said.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 16:26:04 +02:00
wuwiandClaude Opus 5 f3b0b791d6 Trade the tutorial for a screen that counts the dead
The menus were placeholders next to the art. Three screens now: a title over
an empty, dimmed, blood-stained court; a MATCH SETUP screen where each court
wears meters read straight off its own bounce numbers; and BODY COUNT, which
needed the game to start measuring anything at all.

stats.js keeps one capped array of per-run records — all time, last match and
seven days are three filters over it rather than three counters that can drift
apart. Kills file under the body AND the weapon: killZombie only ever knew an
index, so every damage loop stamps the cause on its way past.

The tutorial is gone. Marvin explained what the in-game modals already teach,
and his stage is where the stats live now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 16:01:59 +02:00
wuwiandClaude Opus 5 411bff8724 Describe the two bells that actually hang there
Oliver kept a webhook per branch rather than the single unfiltered one, so the
note now says that. The part worth keeping is why a filtered hook's Test
Delivery always looks dead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 14:50:33 +02:00
wuwiandClaude Opus 5 c78d5a8578 Write down that one bell rings both courts
Coolify's manual Gitea endpoint picks the resource by repo AND branch, so the
single existing webhook already serves staging and a branch filter would only
break it. Noted alongside why Test Delivery can never confirm this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 14:49:59 +02:00
wuwiandClaude Opus 5 4f49a0b4e1 Give staging its own address rather than a spare room in the main house
A subdomain means a separate origin, so a staging run can no longer overwrite
the localStorage the testteam is playing on. That makes the /staging alias
pointless, so the Dockerfile goes back to exactly what is deployed today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 14:21:18 +02:00
wuwiandClaude Opus 5 708ed9c57f Serve the same court twice, so staging can be tried before the crowd arrives
The image now answers at /staging/ as well as /, via an alias to the same
root — that way the staging deploy works whether or not Coolify strips the
path prefix, and the staging branch needs no diff of its own to merge.

The manifest MIME moved from an exact-match location into the stock types
map: an exact match would have missed the /staging/ copy, and a regex
location would have out-ranked the prefix and resolved against root.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 14:20:08 +02:00
wuwiandClaude Opus 5 0ca43bf68f Let the ball follow the mirror, and stop the serve shrinking her
Four things Oliver caught on the phone.

The screen title is the ANNOUNCEMENT face now. A menu heading is a thing that
shouts, the same register the canvas gives SET and CHAPTER — it was wearing the
HUD font, which is the voice for scores and key hints. Bangers carries its own
weight, so it takes size and air instead of Silkscreen's letter-spacing, plus
the padding under it he asked for.

Her toss and smash were shrinking the whole woman. The slice anchors frame 0 to
224 px, and frame 0 was the toss — whose arm is straight up, so the BODY got
squeezed to make room for it. This is the trap already written down for the
raised racket; it just moved to a different sheet. Re-sliced the retained sheet
at the men's toss height (250) instead, so toss/smash/pull come out 250/293/191
against his 250/320/213 and her body matches her own idle again. No re-roll.

The pre-serve ball did not follow the MIRROR. drawChar flips the whole sprite
when a server faces left, so dodging during the bounce swapped his hands over
and left the ball hanging out by the racket — one `* sv.facing` on the offset.
It was wrong in the men's world the whole time, it just took a second body
standing next to it before anyone saw it.

And she reaches further out and higher than he does, so BOUNCE_IDLE's pair is
overridable per tour now (0.46/0.78 against his 0.36/0.68). Measured off the
drawn hand by eye, which is the only way those two numbers have ever been set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 12:37:59 +02:00
wuwiandClaude Opus 5 14a1e5d261 Let the tour be a choice, and give her a body of her own
The court screen picks WHO as well as WHERE now: two tiles above the court
rows, men's or women's singles, remembered like the court is. No new stage —
three rows and a pair of buttons already fill a phone, so the tiles go side by
side and the tap count from splash to play does not change.

Everything downstream is one line: drawChar already built every frame name off
a single prefix, so SK() appends '_f' when the women's tour is picked and falls
straight through to the men's frame for anything without a twin. The horde, the
officials and the ball boys are shared — they are staff, not the draw — but the
turned CPU is not: a male corpse opposite a female player reads as a bug.

Her frames came back as a different woman in every batch — blonde in one,
auburn in the next, greyscale line art in a third — because each was anchored on
the MAN whose pose it was. "newart" repaints the character and "state" is welded
to the corpse palette, so neither could say "the same living woman, a new pose".
That mode now exists, and every batch anchors on her own settled frame.

The death is drawn to frame 3 and recoloured after it. The generator will not
draw a dead woman in any wording worth using; frames 4 and 5 survive that
because by then she is flat and then a heap of cloth, so they are the men's
drawing with the blue shorts painted into her white (see assets/_die_f.py). The
collapse still falls 224/159/117/103/43 straight through the handoff.

Her first bounce frames reached to the shoulder, which is a catching pose, not
patting a ball — redrawn low rather than bending BOUNCE_IDLE to fit a wrong
drawing, so both tours share one apex and the ball meets her palm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 12:22:21 +02:00
wuwiandClaude Opus 5 c75bd5b9ba Pay out every won duel, and stop the popup eating the announcement
Two separate faults, both of which read as "the duel gave me nothing".

The first is mine, from making weaponModal first-sight-only. G.flash ticked
ABOVE the `if (G.modal) return` in update(), so a flash raised in the same beat
as a popup spent its whole 1.1 s counting down invisibly behind the dim. That was
harmless while every tin opened its own modal — but once the modal went quiet the
label flash WAS the announcement, and the trainer's WOUNDS TREATED popup ate it
every single time you won a duel while wounded, which late in a run is every
time. Measured: flash null after 1.5 s of reading, nothing left on dismiss. A
modal freezes the world and a transient HUD message is part of the world, so the
tick moved below the return. It now holds at 1.1 for as long as you read, and
starts counting when you tap.

The second is older and Oliver felt it directly. deliverTin CYCLED the ladder —
TINS[G.deliveries++ % 4] — handing you whatever rung came next even when that
type was already at wCap, where grantWeapon's Math.min clamp makes the delivery a
silent no-op. Bomb and fire fill in three duels each, triple in one, so past
about the thirteenth duel three rungs of four are full and most late duels paid
out in literally nothing. It walks to the first rung with ROOM now. Simulated
twenty wins with nothing ever spent: every one delivers through #16, where before
#10, #13, #14 and #15 were already empty handovers. Past that it says RACK FULL,
which is a real state and not a bug, so it says so plainly instead of miming a
delivery — no boy, no umpire line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 06:49:41 +02:00
wuwiandClaude Opus 5 5a4c6e4359 Give the laser its own voice, and let a body sound like its size
The laser was playing boom() — the BOMB — so the showiest weapon in the game
sounded like the least showy one. zap() is its own: a highpassed crack, two
detuned saws diving through a resonant lowpass, weight underneath so it lands on
a phone speaker, and a triangle that rings after the plane has gone. The dive is
the whole reading; a rising sweep is a charge-up and this thing never charges.

And the NORMAL hit — ball meeting a body without killing it — was borrowing
pock(), the racket thock. That is a bright, dry, stationary-band click: exactly
right for gut on felt and exactly wrong for felt on a torso, which is why hitting
a zombie sounded like hitting a wall. thud() falls instead of sitting still,
which is where the wetness comes from, with one quiet bright tick just behind the
slap: something gave. The machine keeps its pock — it is furniture and it rings.

Both thud() and splat() now take a size, zombieH/1.8, so 1 is a shambler, 1.5 a
brute and 2.04 a chapter-4 boss. Everything pitches down and lengthens with it.
Measured through a patched AudioParam rather than assumed: the body thump under a
hit runs 150 Hz on a shambler, 100 on a brute, 59 on the boss.

splat() got the same treatment while I was in there — a resonant band collapsing
down the spectrum instead of a plain lowpass, plus a second brighter burst a beat
later. One clean hit is a thud; a thud followed by spatter is a body.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 23:23:47 +02:00
wuwiandClaude Opus 5 0c12157702 Find a burst weapon wherever it is on the rack, not just in the first five
endBurst looked its tin up with [0, 1, 2, 3, 4].find(...) — correct for exactly
as long as the rack held five slots. Growing RACK_MAX to 9 meant a burst weapon
picked up into slot 5..9 could never be found, so it was never spent: you fired
it and the count stayed where it was, forever. Oliver hit it with the beacon, the
rocket and the flamer, which together with the gun are precisely the four
kind:'burst' weapons — endBurst is the only spend path that searched for its slot
instead of being handed one.

The serve weapons were never affected, and that is the tell: bomb, triple, fire
and the laser all spend through spendSlot(G.weapon), the live index. Checked the
laser end to end at slot 8 rather than reasoning about it — 6, 5, 4, 3, 2, 1,
gone.

Fixed at the invariant rather than at the call site. The rack has always had a
comment saying everything reads through the slot accessors so nothing else needs
to know how it is stored; slotOf(id) is now the fourth one, and it is the single
place that knows the range. Verified all four burst weapons arm from a real tap
in slots 5-8 and leave the rack when the burst expires.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 23:15:14 +02:00
wuwiandClaude Opus 5 ebce08199f Turn the praise down, and stop the footnotes shouting
The band made PERFECT earnable, which was the fix — but you learn a band, and
then the HUD is talking on every other serve again whatever the word says. So
the praise goes behind a shared cooldown (TUNE.praiseEvery, 14s), the same model
the umpire's asides have used all along for exactly this problem. Eight
band-perfect serves back to back now yield one word. The bright click in the pull
is the reliable feedback for a good draw; the word was only ever garnish, and
garnish on every plate is just food.

And the free-floating slot drops 24 -> 18. It shares that slot with CLEAR THE
COURT, DOUBLE KILL, RETURNED and GAME N — half of which are a footnote to
something you already watched happen. Sharing the loud FACE with the milestone
cards is right; sharing their SIZE made every footnote read as a milestone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:53:10 +02:00
wuwiandClaude Opus 5 9c7ee42bfa Earn the praise, and give the quiet face a shadow too
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>
2026-08-03 22:21:56 +02:00
wuwiandClaude Opus 5 55c0f75c37 Give him a sidestep, and split the messages into two voices
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>
2026-08-03 21:48:24 +02:00
wuwiandClaude Opus 5 99774ab4be Shape the cone by hand, and give the playground its knobs back
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>
2026-08-03 17:58:51 +02:00
wuwiandClaude Opus 5 ac1d5fbccd Draw the laser like the ball's aim band, and let the two be compared
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>
2026-08-03 17:26:18 +02:00
wuwiandClaude Opus 5 601aa934ad Make the plane solid, and take the posed scene back out
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>
2026-08-03 17:21:21 +02:00
wuwiandClaude Opus 5 44419d9ce1 Pose the laser scene and hold it still, so it can be poked at
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>
2026-08-03 17:16:10 +02:00
wuwiandClaude Opus 5 b44fcd8465 Take the cone down and turn it blue
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>
2026-08-03 16:04:04 +02:00
wuwiandClaude Opus 5 8f16d085f0 Stop bodies blinking out, and give the cone a core and edges
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>
2026-08-03 15:58:16 +02:00
wuwiandClaude Opus 5 337def8526 Cut them with the laser plane instead of painting over them
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>
2026-08-03 15:48:08 +02:00
wuwiandClaude Opus 5 da9cca2f23 Hide the racket, and light the beam all the way to its edge
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>
2026-08-03 15:20:33 +02:00
wuwiandClaude Opus 5 6e88fd5bb9 Burn them to ashes, and give the burst back its bar
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>
2026-08-03 15:05:56 +02:00
wuwiandClaude Opus 5 91053b4c35 Fire the laser as light, and stop him jogging on the spot
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>
2026-08-03 14:44:57 +02:00
wuwiandClaude Opus 5 46e268973d Triple-tap the scoreboard for a sandbox
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>
2026-08-03 14:20:14 +02:00
wuwiandClaude Opus 5 282f5145a9 Make the pink weapons throw themselves, and let the big bodies soak
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>
2026-08-03 14:13:05 +02:00
wuwiandClaude Opus 5 25f915f236 Fill the rack yourself, and roll barrels out that fill it
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>
2026-08-03 12:09:28 +02:00
wuwiandClaude Opus 5 4cda6bde76 Make the bosses move, and put six of them and three new bodies on court
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>
2026-08-03 11:45:35 +02:00
wuwiandClaude Opus 5 0774a2a31d Delete the wake variant; say PULL DOWN and point only downward
The switch and the losing branch are gone — a fired band is a short streak
behind the ball, full stop. TUNE.shotTail, WAKE and the tailMode branching all
removed; the aiming band is untouched as ever.

THE IDLE DEMO NO LONGER POINTS UP. It grew chevrons up-court as the pull grew,
because the shot going the opposite way to the thumb was meant to be the
lesson — but an up arrow on screen is an instruction to swipe up, and swiping
up is the one gesture this game does not have. The only direction drawn now is
the one you actually make: the dotted track, the thumb sliding down it, and a
down arrowhead at the end, clear of where the thumb lands at full draw.

And it says so in words. The mime alone was too abstract to read as "drag your
thumb down", so PULL DOWN sits under the arrow in the pixel font.

Which makes the text slot below it redundant while the demo runs, so it shuts
up rather than printing the same instruction twice 40 px apart. The wording
everywhere else moves BACK -> DOWN too: "back" is the ambiguous word that could
be read as up-court, which is exactly the confusion here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 22:02:41 +02:00
wuwiandClaude Opus 5 dbc0d5baee Default the fired band to the tail behind the ball
shotTail 0 -> 1. The panel is keyboard-only ('T'), so on a phone the default
IS the setting — noted next to the knob so the next person doesn't assume it
can be flipped in play.

Verified on the default build: the drawn slice runs from z 21.4 back at the
racket side to z 18.84, which is the ball to the centimetre — entirely behind
it, ending on it. Nothing ahead. 600 frames clean.

Keeping the losing branch until a real playtest confirms the pick.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 21:44:14 +02:00
wuwiandClaude Opus 5 6ce52a6e85 Two ways to spend the aim band, switchable in the panel
The laser look came from carrying the AIMING picture unchanged into the flight.
While you pull, a bright saturated line spanning you to the target is exactly
right — it is the information you are deciding on. Once fired it is spent, and
a projectile drawing its own future in a continuous saturated line from itself
to what it is about to hit is the visual definition of a targeting beam. No
real ball shows where it is going.

So a fired band now desaturates toward dust, collapses, and shrinks to a short
streak. TUNE.shotTail picks which side of the ball that streak sits on:
  0  a WAKE ahead of it, dissolving — your erase idea, beam cut down to a wisp
  1  a TAIL behind it, nothing ahead at all — conventional projectile language
The shadow follows the same slice, because "nothing ahead" has to include the
ground or it still gives the future away. The target mark fades with it: a mark
still burning on the shambler is the other half of the laser.

Both leave the aiming band untouched.

Built the collapse squared first and it was wrong — both variants were gone by
a third of the flight, which is not "less like a laser", it is no streak at all,
and the strobing at release is what this trail exists to prevent. Linear now.
The SHORT SLICE is what kills the beam; the fade only has to make sure nothing
is still burning when the ball lands.

Flip it live on the phone: T panel, shotTail, type 0 or 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 21:13:34 +02:00
wuwiandClaude Opus 5 7284ca08f8 Pace back to 1.2x, and unweld the racket from over his head
PACE 1.3x -> 1.2x on all four numbers: 19.2 / 14.4 rally, 22.8 / 26.4 serve.

The strength-to-velocity relationship you suspected was already there is: a
splatter serve runs 26 m/s at the minimum pull, 36 at the kill threshold and
49.2 at a full draw. The draw very nearly DOUBLES the pace (1.89x), because
serveQualitySpeed carries more of the pair than serveSpeed does. Nothing to
change unless you want it steeper, and that is the one knob.

THE RACKET-ALWAYS-UP LOOK WAS A LATCH, not the art. hitServe sets pose 'serve'
with poseT 0; that only expires after POSE_TIME (0.38 s); and p_pull, the
wind-up frame, is only ever selected from pose 'idle'. So firing faster than
0.38 s reset the timer before it could expire, he never returned to idle, the
pull frame was never drawn once, and he stayed welded into p_smash — arm
extended overhead with balls appearing out of the strings. Exactly the late
game, because that is where the fast successions are.

serveToss now clears a stale 'serve' pose: a new wind-up ends the last shot's
follow-through. At ~4.6 shots/sec the cycle is now pull x5 frames, smash x8,
pull x5 — a real swing. Slow fire still plays the whole ritual: bounce, pull,
smash, idle, bounce.

Caveat: past roughly 8 shots/sec each pose gets 1-2 frames and will read as a
flicker rather than a swing. There is no fix for that short of drawn
follow-through frames; the poses are stills.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 19:14:12 +02:00
wuwiandClaude Opus 5 19c13986c5 Ball 30% faster, all four pace numbers together
baseShotSpeed 16 -> 20.8, qualityShotSpeed 12 -> 15.6, serveSpeed 19 -> 24.7,
serveQualitySpeed 22 -> 28.6. All exactly 1.3x, so nothing about the relative
feel moves: rally and serve, tennis and splatter, his ball and the CPU's, which
share baseShotSpeed.

Swept the full-pull serve down the court: 53.3 m/s against the old 41 at every
range, flight times ~23% shorter (30% more pace is 23% less time in the air).

One exception, and it is geometry rather than tuning: a shambler standing right
on the tape is loft-limited. That shot has to come almost straight down, so the
net-clearance loop trades the extra pace straight back and it ends up only ~14%
quicker. Nothing to fix unless it reads as sluggish next to everything else, in
which case serveSpin is the knob — a harder dip buys pace over the tape.

Tennis serves 8/8 into the box at the new pace; 900 frames clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 18:53:33 +02:00
wuwiandClaude Opus 5 2edabe6c64 Aim the ball at the chest, and stop lobbing at a net that isn't there
THE BALL WAS SOLVED TO THE GROUND. solveShot's terminal height was a hardcoded
0.08 — ball-on-the-grass — so a splatter serve came down past the shambler and
registered at the ankles however the mark was drawn. It now takes an arrival
height, and a lane shot aims at the body: zombieH x Z_CHEST, scaled off the
same height table render draws with, so a brute is struck on the brute rather
than at the height a shambler's chest happens to be. Measured at the frame the
wound registers: shambler 1.11 m of 1.8, brute 1.64 m of 2.7, and an empty lane
still lands on the grass. The aim mark reads the same `ty`, so mark, arc end
and impact are now one point instead of three guesses.

AND A BUG FOUND ON THE WAY. The net-clearing loop ran for shots that never
reach the net. A shambler on your own side sits short of the tape, so `tN` is
still positive — the net is ahead in the direction of travel, the ball simply
lands first — the height test there is far below the tape, and the loop spent
all twelve cuts. Every same-side shot left the racket at 28% of nominal pace as
a slow loop. That is exactly the shot that needs to be fast: those are the ones
about to eat you. Guarded on the shot actually crossing the net, in serveArc
and in doHit's copy. Flight time to a body at z=16 drops 0.71 s -> 0.20 s.

THIS ONE IS A FEEL CHANGE YOU DID NOT ASK FOR — near shots are now fast and
flat where they used to loop. Say if you want the lob back.

Tennis and the duel still serve into the box, far-side shots still clear the
tape, 900 frames of splatter render clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 18:49:47 +02:00
wuwiandClaude Opus 5 ff9e09e3eb The body mark was behind the body; the yellow ring was never mine
Two bugs, one report.

THE MARK WAS DRAWN UNDER THE SPRITE. The whole band, target mark included, is
painted before the sprite pass — which is right for the arc, since a nearer
shambler should occlude the part of the flight behind it. But the mark is a
mark ON a shambler, so it went down first and the body was then drawn straight
over the top of it. It has never once been visible. Split out as drawAimMark
and called after the near-side actors, which is the only place a highlight on
a body can go.

THE YELLOW CIRCLE WAS G.outMarker, not the aim at all. It is the tennis in/out
call — "did that land in?" — and splatter never asks: `out` could not be true
there, so it was an unconditional yellow ring on the grass carrying no
information, sitting under the shambler for 1.3 s. That is longer than the gap
before the next pull, which is why it was there "most of the time" and why it
looked like the aim marking the feet. Now set only in tennis, from both the
serve and the rally swing.

Checked: tennis keeps its ring on serves AND rally shots, splatter has none
anywhere, and the mark now draws over the torso at every pull strength.

Note this leaves a splatter VOLLEY with no landing feedback at all. It had a
ring that said nothing before, so nothing was lost, but say if the volley now
feels blind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 18:38:08 +02:00
wuwiandClaude Opus 5 0ce857fedc Let the ball eat its own aim band, mark the body, grow the machine
Four things from the phone.

THE BAND NO LONGER BLINKS OUT. Cutting it at the instant of release made a
fast rally strobe — band, gone, band, gone. hitServe now records the arc it
actually fired (error included, so the ball tracks what it is consuming to
within a centimetre) and the ball eats it from behind as it flies. The aim
resolves into the shot instead of vanishing. It ends early when the ball does,
because a shambler swallowing the shot is a cut you want.

THE TARGET MARK SITS ON THE BODY. A ring on the grass pointed at the one part
of a shambler the shot has nothing to do with — the ball strikes anywhere under
1.9 m and the wound lands on the torso. So the mark rides at chest height when
there is a body to hit and lies flat only when the lane is empty. Filled and
washed out in the band's own colour instead of a hard 2px ring, which stops it
reading as a decal stuck to the lawn.

x_howto REDRAWN for the current gesture: thumb dragging back to a print, the
shot leaving as a curved band onto the shambler's chest, shadow dots beneath.
The old one still taught a straight arrow.

MACH_H 1.15 -> 1.55. Waist high read as a prop you could ignore.

Verified on a cache-busted build (the pane was serving a stale config.js, so
the first pass measured nothing): preview velocity and gravity still identical
to the fired ball, same flight time in the trail, unravel smooth to the hit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 17:41:18 +02:00
wuwiandClaude Opus 5 3a91d87b08 Show the shot itself: one solver, one band, no meter
The reticle was a chevron trail on the ground and a draw meter across the
bottom. The trail was a RAY — it pointed at the target but said nothing about
the ball getting over the net — and the meter sat in the apron, which is
exactly where your thumb is while you pull, so the one readout that mattered
mid-gesture spent the whole gesture underneath your hand.

Now the aim IS the flight. previewServe runs the same targeting and the same
loft solve hitServe fires with (both extracted, neither duplicated), so the arc
you see is bit-identical to the ball you get — verified: vx/vy/vz/g all match
to zero at quality 1.0, where the aim error vanishes. A preview drawn from a
second copy of the maths is worse than no preview; it lies with confidence.

One object now carries all three readings, up-court where you are looking:
the parabola is WHERE, the colour is WHETHER (cool blue still means it only
staggers), and the band's thickness and opacity are HOW MUCH — which is the
part a hue cannot carry on its own. Because the arc ends at the auto-picked
target, a weak pull and a full one at the same shambler draw the same curve in
different colours: "pulling further does not reach further" made visible.

A shadow tracks the arc along the lawn. An arc hanging in the air is ambiguous
about where it comes down, and that trail is what pins it.

The apron is left to the gesture demo alone, drawn bigger now that it owns the
space, with its chevrons lifted clear of the player's legs.

Tennis and the duel still serve to the box, not at shamblers — checked both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 17:16:21 +02:00
wuwiandClaude Opus 5 75936d3324 Fall the light off across the run-off, so it reads as ground and not margin
Riding the court up left a wide band below the near baseline, and `pal.out` is
one flat colour — so the perspective visibly stopped partway down the screen
and the rest read as dead margin rather than as the back of a court.

A gradient from the baseline to the bottom edge fixes it: the band gets depth,
the rack gets a frame, and what darkens is exactly the area the thumb covers
anyway. Starts at alpha 0 on the baseline, so there is no seam.

A procedural "trodden ground" variant (scuffs and drag marks) was built and
cut: random ellipses read as unexplained puddles, not as wear. That idea wants
authored detail and gore that accumulates over a run, not noise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 15:57:36 +02:00
wuwiandClaude Opus 5 0fe8f37a81 Ride the court high enough to actually pull under him
The slingshot never had room. On a 375x812 his feet sat 658 CSS px down an
812 px screen, leaving 112 px of clear court under him — and a full draw is
130. The wind-up physically did not fit, so the thumb was driven onto the iOS
bottom edge every rally, where a swipe down is Reachability and the system
takes the gesture. The gesture was never the problem; the geometry was.

So the portrait horizon is derived from the BOTTOM now: park his feet a full
draw plus the rack plus slack above the screen edge, and let the horizon fall
where it must. What gets shorter is the stand above the court, and that was
only ever crowd. 375x812: horizon 121 -> 51, the clear band under him 112 ->
210 px, and a full pull from his feet now ends 122 px clear of the edge.

Landscape is untouched. `horizon` is still the live knob in the T panel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:39:05 +02:00
wuwi 8ea749f380 Revert "Point at them, then wind up: the lane comes from where the thumb lands"
This reverts commit 1798fc67b8.
2026-08-02 14:35:25 +02:00
wuwiandClaude Opus 5 1798fc67b8 Point at them, then wind up: the lane comes from where the thumb lands
The lane used to come from the pull's own direction, so aiming up-court was
always a downward drag. Every shot ended lower than it began, the next touch
starts where the last one ended, and over a horde the thumb walks itself into
the iOS bottom edge — where a pull down is Reachability and the system takes
the gesture out from under you. A web page cannot refuse that.

So touch-down picks the lane now (a ray from his chest through the thumb) and
the pull after it is power and nothing else, in whatever direction there is
room for. Aiming is what drags the thumb UP the screen, because that is where
the shamblers are.

The cost, and the thing to feel: the lane locks when the thumb lands. You can
no longer sweep the aim while drawn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:22:55 +02:00
wuwi 1337c1f47d Revert "Aim by dragging at them, so the thumb stops walking into the iOS edge"
This reverts commit d4bbcfc080.
2026-08-02 14:10:12 +02:00
wuwiandClaude Opus 5 d4bbcfc080 Aim by dragging at them, so the thumb stops walking into the iOS edge
The slingshot pulled back, which meant every shot ended with the thumb lower
than it started — and the next touch begins where the last one ended. Over a
horde it walks itself into the bottom edge strip, where a pull down is
Reachability and iOS takes the gesture. A web page cannot refuse that.

So the drag now points AT the target: the thumb ends high and drifts up-screen,
away from the edge. It also matches the flick-swing, which always aimed this way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:05:04 +02:00
wuwiandClaude Opus 5 b23a3a8aaf Turn the theme up, and let START GAME catch the light
Menu theme 0.5 -> 0.65.

The gold frame comes off START GAME — both buttons share the one slate border
now, so the pair reads as a pair, and a light sweeps across the START plate
instead: over in ~0.8s, then still for the rest of a 4.5s cycle. The sweep is a
::after with pointer-events:none so it can never eat the tap, and it is off
under prefers-reduced-motion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 17:44:21 +02:00
wuwiandClaude Opus 5 d3aabc4bd6 Fold the menu into the title screen, and stop the theme restarting
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>
2026-08-01 16:25:20 +02:00
wuwiandClaude Opus 5 2d4fa79258 Give the menu buttons room, a frame, and bigger type
The logo now sits exactly as it does on the title screen — same width, no menu
override — with 30px under it so the blood running off its bottom edge stops
touching the first button.

That button also had no top border to cut into: the crop was a BOTTOM band of a
taller plate, so it carried the plate's bottom border and nothing above. Both
plates are cropped to their interior now and CSS draws one consistent frame on
each, gold for START and slate for TUTORIAL.

Lettering stays live text and goes to 15px with wider tracking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:25:54 +02:00
wuwiandClaude Opus 5 4a034773aa Draw the tutorial properly, and put the logo and gore on the menu
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>
2026-08-01 13:43:43 +02:00
wuwiandClaude Opus 5 9a65a50cc4 Put a menu in front of the game, with Marvin to explain it
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>
2026-08-01 13:31:00 +02:00
wuwiandClaude Opus 5 60f7ce2cf1 Give the run strip a panel, round the rack, fix the duel's hint
The NEXT/GAME row was bare text on the court. That was legible over one dark
green lawn and is not over a sunlit clay crowd or a floodlit blue one, so it
gets its own rounded panel — the same thing the scoreboard art above it already
relies on. It states the sets banked now too; they had only ever appeared in the
scoreboard's own per-row column.

The weapon rack's five slots are rounded, which is what they are: buttons.

The duel is played under tennis rules — no slingshot, no rack, serve to the box
(see the !G.interlude guards in solveShot and the aim reticle) — but the bottom
hint was still telling you to pull back to aim, which is the one thing that does
nothing there. It reads as a tennis serve now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 12:21:53 +02:00
wuwiandClaude Opus 5 af30db2ff3 Make the regen pipeline check the kit, and fix the swing and toss
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>
2026-08-01 11:51:27 +02:00