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>
All four stages drawn from the healthy body in two sheets, so he keeps the same
bulk the whole way down instead of slimming as the wounds get worse — one arm and
his pole, both arms, ribs open, then headless.
The second frame of each stage is now a MIRROR of the first, not a generation.
Every attempt at drawing them moved the injury between the two feet — this is the
third fix for the same complaint — and a mirror physically cannot: the wound
stays on the same part of the body, and flipping a standing figure IS the stride
swap the B frame exists for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Oliver had the diagnosis exactly right: the raised racket is part of the sprite,
so the toss and smash frames were 29% racket by height — and the game scales a
sprite off its TOTAL height, which shrank the body to fit. Measured 71% body
against the standing frame's 98%. Both are scaled so the BODY lands at standing
size and the racket now extends above the frame instead of squeezing him into it.
The bounce frames are redrawn too: racket in the left hand, right hand empty and
reaching down to the side the game actually drops the ball on, and no ball drawn
into the sprite at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The generator drew the second frame of that wound stage as a MIRROR of the first,
so the missing arm and the dragged pole swapped sides on every step — which is
the "wound on the left arm on one foot but not the other" Oliver saw, and it was
on the sheet, not in the frame assignment. Flipping the frame back puts the
injury where it belongs, and a horizontal flip IS the leg swap a second stride
frame wants, so the walk still reads.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The playtest list, art side. The generator had been copying the very frames I
wanted changed, so:
- The player is drawn FROM BEHIND again in every pose — standing, bouncing,
serving, swinging. He had turned to face the camera in the redraw, which is
also why he "looked at you" in different poses.
- The serve frames are back at full size. That batch had been anchored on the
toss frame, so the whole serve set came out about a fifth too small.
- The runner CHARGES AT YOU down the court instead of sprinting sideways across
it, which is what he actually does in the game.
- The brute's wound stages are drawn a pair at a time from their own A frame, so
the injury stops moving between the two feet.
- The bounce frames had a ball painted into them while the game draws a live one
on top — he was bouncing two. The painted one is gone, and the live one now
falls on the side the sprite actually reaches for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reference sheet is what it imitates, so building that sheet out of the very
sprites I was trying to change is why the front-facing player came back
front-facing and the side-on runner came back side-on. A batch can now name
`ref_only` — the frames it looks at — separately from the frames it replaces:
the back view is drawn from the one genuine back-view frame, the charging runner
from a front-facing zombie, and each brute wound pair from its own A frame so the
injury cannot wander between the two.
Also stopped colour-matching a REPOSE. Fitting a differently-posed frame onto the
old one's colour statistics is what greyed out the serve shorts; when the pose
changes the generator's own palette is the better answer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three from the playtest, all code:
Every side-on sprite is DRAWN facing left, and nothing ever mirrored them, so a
zombie walking right moonwalked — obvious the moment they close on you across
your own half. They face where they last walked now. The front-facing ones (the
turned CPU, the machine, a chapter boss) have no left or right and are left alone.
The racket zombie holds a position and only moves when the ball makes him, so a
time-driven walk cycle flicked him between two frames on the spot. His stride
steps off ground covered, like the brute's — standing still is standing still.
And the pack no longer stands on the body twitching for as long as the summary is
up: once it appears they break up and shamble away up-court. Measured — feeding
at 3s, dispersed to 3m by 6.5s.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two that kept failing land as two-figure sheets — a dragged pole or a
held-up chair is wide enough that four to a sheet either merge into one frame or
run off the canvas whatever margin the prompt asks for. The chair spectator came
out the best of the lot (0.15-0.24 to 0.42-0.52).
Also caught x_boyrun3 and x_boyrun4 sitting at the old fidelity next to the two
frames of the same run I had already redrawn — my batch list only covered half
the cycle.
The roster now averages 0.341 inner-edge density across 111 character sprites,
against 0.237 for z_mach2. What is deliberately left alone: the chapter bosses,
the judge, the ball machine and the weapon icons — Oliver named those as the ones
that were already right.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four more batches: the racket zombie's two wound stages with their stride twins,
the umpire and the ball boy's sprint, his mop-carry walk and his mopping cycle.
Detail roughly doubles on all of them.
Also stripped the last dozen key-colour pixels off p_die5's rim — edge pixels
only, because a racket's strings are legitimately pink and a blanket filter
would eat them.
The brute's wound stages and the chair spectator are re-running as smaller
sheets: four figures carrying a dragged pole or a held-up chair either merge
into one frame or run off the canvas whatever margin the prompt demands, so
they go two at a time.
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>
Both full sets through the same pipeline: stances and ball-bounce, serve toss,
smash and both swing frames, the four-frame run, and the five-frame death. The
opponent's ten frames with them. Inner-edge density roughly doubles across the
board — the serve frames go 0.22-0.32 to 0.41-0.54.
_regen.py retries a rejected batch itself now (default three attempts). A
rejection is usually just a bad roll — two figures drawn close enough that the
slicer sees one frame, or a limb off the canvas — and re-rolling fixes it without
me sitting in the loop.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
assets/_regen.py plus a batch file: build a reference sheet from the sprites in
the repo, ask for a HIGH-resolution redraw, slice with _fine.py, colour-match
each frame onto the one it replaces, install. The point of the whole thing is the
style block — the old prompts specified coarse art and got it.
It refuses to install a bad sheet. Wrong frame count, any figure touching a
canvas edge (that is what clipped z_f1 in the first place), or a redraw that is
not measurably finer than what it replaces, and the batch is left alone with a
reason printed. Re-running a batch is the fix; three of the first eleven needed
it. The bar is 1.25x inner-edge density rather than 1.5x, because a sheet that
already has blood on it starts high and a genuinely finer wounded body still
scores a smaller gain.
Done so far: both shamblers and their wound skins, the brute's walk and its two
sets of wound stages, the steward and his wound skins, the turned CPU.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
I had been measuring the wrong thing. The complaint is not the PNG's size or its
upscale factor, it is how fine the DRAWING is — how many pixels make up the
figure, and whether there is room in it for teeth, fingers, seams and folds. On
that, my own prompts were the cause: every sheet this session ordered "every art
pixel is a big square block about 8 screen pixels across" and "flat blocks of
colour, no shading", which is a specification for coarse art. The generator
followed it exactly.
New recipe, proven on the runner's four frames: ask for HIGH resolution — "build
the figure out of 90 to 110 pixels of height instead of 40" — and enumerate the
detail it should spend them on (teeth, sunken eyes, individual fingers, tracksuit
seams and side stripe, torn edges, ribs, laced shoes, four or five shades per
material with hatched shading). Inner-edge density went 0.15-0.22 to 0.43-0.49;
z_mach2, which Oliver holds up as the good one, sits at 0.24.
z_f1 has been clipped on its right edge since it was made — the generator ran the
figure off the sheet. The new sheets demand 150px of empty margin at every edge
and it is whole now.
The rest of the roster is still the coarse style and wants the same pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audited every sprite by detecting the art-pixel BLOCK SIZE each one was actually
drawn at. The set splits cleanly in two: 29 sprites are 112 art-pixels tall on a
2-pixel block — literal 2x upscales, carrying half the detail — against 200-224
for everything drawn since. z_f3 and z_bw are in the first group, z_chairt and
z_mach2 in the second, exactly as Oliver called it.
Regenerated at full detail so far: the player's idle pair, toss and smash (he is
on screen the whole game, and his own run and death frames were already fine, so
he was mismatched against himself), plus the two wounded shamblers and the
runner's three frames — where the wound skins were the worse case, since their
newly drawn second frames were full-detail and their A frames were not.
New pipeline step for it, assets/_fine.py: detect the generator's art-pixel grid
and snap to it, common-scale in ART pixels off a chosen anchor frame (so a smash
with the arm overhead stays legitimately taller), quantise back to the sheet's
own palette, and de-fringe the key colour twice — the generator does not always
return a pure magenta and the halo survives as a rim on a green court.
Still half-resolution, in order of how much they are seen: the brute's seven
wound stages, the seven opponent frames, the racket zombie's two wound skins,
and x_ump / x_boyrun1 / x_boycarry2 / x_boycarry3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two of the same mistake, both called out.
The wounded feet still did not move because a mirror cannot move them: flipping
the legs swaps WHICH leg leads but keeps the two feet in the same two places, so
the spacing never changes and the body still slides. Measured it — mirrored feet
sat at -35/59 against the original's -34.5/60.5. What a walk actually needs is
the PASSING pose, feet together under the body, so the gap opens and closes.
Drawn now for all three wounded skins: 75->65, 95->68, 79->69 px between the
feet. (The mirror also clipped the steward's hanging arm into a floating
fragment, which is what Oliver spotted. Gone with it.)
The death was one sprite scaled down, which miniaturises a man rather than
killing him. It is five drawn frames now — arched back, knees, hands, face down
in the pool, a flat heap — each genuinely shorter than the last (100/84/60/47/20%
of standing height, common-scaled off one sheet) and each drawn at its own
height. No scaling anywhere in the sequence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pair alternated but the feet stayed put: the generator had swapped the arms
and torso and redrawn the same legs. Measured it rather than squinting — the
lower third of the two wounded frames differed by 3.7% and 4.6%, where a healthy
walk pair differs by 60-70%. That is a body sliding, which is exactly what Oliver
saw.
Regenerated with the stride as the entire brief (33% for the shambler), and where
the generator would not move them, mirrored the leg band in place instead: a
side-on walk swaps its stride under a horizontal flip of the legs alone, same
pixels, same palette, no dice roll. The coated shambler and the steward are that
(28% and 31%).
Audited every wound pair the same way while I was in there — the racket zombie's
two stages and the brute were already fine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The old death was a red tint and a shake for half a second, then a puff of gore.
That reads as the sprite having a moment, not as being eaten.
Now it takes four and a half seconds and it is a crowd doing it. You are dragged
to your knees fighting it (new frames, p_die1/p_die2, alternating), blood coming
out of you the whole time — a constant spray and a stain that keeps widening,
with the wet work on a loop underneath. Every body on court drops what it was
doing and converges: each claims a spot in a ring around you, kept from wherever
it was standing, so they crowd in from all sides instead of stacking on one
pixel. Then you are pressed flat into the ground while the ring closes over you —
you are drawn INSIDE the crowd's z order, so the ones between you and the camera
hide you exactly as they arrive — and what is left is the mound, with them still
feeding over it when the summary finally fades in.
Also: the wounded shamblers were stepping their two frames at half the cadence of
the healthy four, which reads as frozen next to the crowd they walk in. Same
clock now. (They were animating — verified frame by frame — just too slowly.)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pulse was drawStatic being told a height instead of reading one. _slice.py
common-scales a sheet, so a crouched frame is genuinely shorter — 211 px against
its neighbours' 224 — and forcing every frame to the same on-screen height
stretched it back up, so the body swelled and shrank once per stride. Worst on
the brute because it is the biggest and slowest. The rule already existed for the
wound stages; it now applies to every frame, once, which also settles the
shamblers and the steward (their cycles vary by up to 9%).
A chapter boss now dies in its own beat instead of under the card announcing it:
the world drops to a third speed for 1.2 s, the screen kicks, a shockwave ring
blows out through 90 extra particles and six stains, and the CHAPTER DOWN card
holds off until the pieces have landed — about 1.4 s, measured.
Cards can hold off in general now (`wait`). First cut only delayed the card's
countdown while still drawing it, which is worse than no delay: caught it in a
screenshot before it shipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last two bodies still shuffling on two frames. Both get their in-between
strides, generated against their own art and fitted per channel onto it.
The chair-down stagger was walk frame 3, which a four-frame cycle would have
walked straight through — it is its own sprite now (z_sdown), picked by state
rather than by where the cycle happens to be.
Also fixed a strobe that predates all of this: z_c2's racket was drawn BLUE
while every other frame of the same zombie carries a brown one, so it flashed
once per cycle. Remapped onto z_c1's racket palette.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It was furniture in the most literal sense: bolted to one spot, three hits from
scrap, and when it died it bled and a ball boy came out to mop it.
- It trundles to a new spot every seven seconds or so, and CANNOT fire while it
rolls. That is honest (a rolling cart cannot aim) and it is the window you use
to close on it — but it also means it stops being a landmark you can ignore.
- Six hits, not three, and it wears them: dented at two thirds, split open and
sparking at one third, smoking where it stands once it is that far gone.
- It comes apart as MACHINERY. No blood decal, no gore mound, no mop boy — a
flash, sparks, torn metal and a column of dark smoke over a scorch mark. Smoke
is a new particle flavour that rises and spreads instead of falling.
Damaged sheets keep the healthy cart's footprint: the generator drew the smoke
plume into the sprite, which would have shrunk the cart as it took damage, so
the plume is cropped off and the smoke is drawn procedurally where it can move.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A wound skin was a single still, so anything hurt froze on one frame and slid
across the court. Every skin has its …b twin now — the stride swapped, stepped
off the same clock as the healthy walk — for both shamblers, both racket-zombie
stages, and the steward, who had no wounded state at all.
The steward also died like a shambler at 3 hp, which made a bigger body read as
decoration. Five now, with a wound skin to show the second half of that. hpMax
is recorded on every body that has hp, so "hurt = show the wound" is one rule
instead of a per-variant constant to remember.
First pass at the wound frames was rejected on sight and deserved it: the
generator drew small figures with fine pixels on a GRADIENT background, and a
nearest-neighbour downscale to sprite height shredded the outlines. Regenerated
two at a time, drawn large, chunky pixels, flat background, hard black outline —
then fitted per channel onto each frame's own healthy twin.
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>
They spawn BEHIND the far baseline, at negative z, and the frames that step off
distance walked used a plain %, which keeps the sign of the dividend:
floor(-1.5 / 0.25) % 4 is -2, so the pick asked for 'z_g-1'. drawStatic draws
nothing for a sprite that does not exist, so the body flickered until it walked
past z=0 — worst on exactly the ones Oliver noticed, because only the heavy
bodies step off distance. The chapter boss had it too, spawning at z=-1.2 and
falling through to an undefined frame name.
One positive-modulo helper at the four picks. Verified across the whole approach
from z=-1.6: every frame resolves to a real sprite now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Last body still walking on two frames, and the slowest thing on court, so the
lurch was the most visible. Same treatment as the player: the whole cycle
regenerated in one pass, then fitted per channel onto the mean and sd of the
frames that stay — the four wound skins — so a healthy brute and a wounded one
are the same colour (mean error about 1 of 255).
Its cycle steps off DISTANCE WALKED, not time, the same rule its wound lurch
already used: at brute pace a time-driven cycle is pure foot-slide. Four frames
over the same metre, so the cadence is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two frames is a lurch, and he is on screen the whole run — during a horde he is
the only thing moving. Regenerated the whole cycle in one pass rather than
bolting two new frames onto the old pair, so the four are consistent with each
other, then fitted them per channel onto the mean and sd of the frames that
stay (idle + swing): mean error under 3 of 255, so he does not change colour
when he starts moving.
The pick is frame-count driven, not hardcoded — the opponent has no run3/run4
and stays on two, and the cycle RATE is unchanged either way: more frames
subdivide the stride, they do not speed it up.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A new ordinary body between the shambler and the brute: a stadium security
steward in a hi-vis vest, 2.15 m, three hp, a touch slower. Deliberately the
mildest thing in the advanced pool — no wound skins, no special rule to learn —
so he can unlock early (game 4) and thicken the ordinary crowd, which a mix of
two shamblers never did. A fifth of horde spawns come out as stewards too, so a
grown wave is not one body dying at one range.
Sheet generated against the existing shambler walk (assets/_sheet_dwalk.png),
sliced to a 4-frame cycle, so he shambles as smoothly as the crowd he walks in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Game 30 is both a set and the third chapter boss. pendingHorde is a LATCH: the
set arms it and the next 'over' beat to expire cashes it — and with the boss
outranking the set at that clear, the next beat to expire was the chapter card.
The wave arrived on top of the boss.
The latch is now left alone while a boss fight or a duel is running, so the beat
after the fight cashes it: the set still gets its horde, just not on top of the
thing that outranked it. Verified both halves — ten seconds of boss fight with
the latch armed and no spawn, then the wave arriving once the boss is down.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Researched it instead of guessing again. Launched from the homescreen, WebKit
resolves the DYNAMIC viewport and every env() inset once against a provisional
geometry and does not revisit them until the geometry is "exercised" — a
rotation. That is the whole bug, and it also explains why listening for a signal
could never fix it: nothing fires, so there is nothing to observe.
Two known workarounds, both applied. Standalone has no toolbars, so 100vh is
correct AND stable from the first frame — dvh only exists for the browser's
disappearing toolbars, so it now applies only there. And toggling viewport-fit
off and back on at boot forces WebKit to recalculate, without the user rotating
anything.
Countdown moved up off the ad boards — the strip's own projected line is its
ceiling now, so it stays in the stands whatever the phone does to the layout,
with a 1px shadow to carry it over the crowd.
Sources: gist.github.com/fozzedout/5e77925381991a9570151550992baf14
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Viewport, again — the homescreen app was the case that broke it. Watching for a
signal cannot work there: launched standalone, iOS resolves the CSS viewport once
against a provisional box and never re-resolves it, so no resize, no layout
change, nothing for a ResizeObserver to observe. Only a rotation forced the issue.
So the CSS box is now a proposal: visualViewport says what is really visible and
the height gets pinned in px to match, re-read on every fit — plus a short settle
salvo after launch, since that launch announces nothing.
- The countdown skipped the transition, where overT is parked at 9999 to hold the
point flow. It read as RESUMING IN 9999 after the first game you ever win.
- Countdown moved off the HUD line into the open, at a readable size.
- A returned shot is a glancing blow now, not a mirror. Reversing the incoming
line sent every parry straight back up it — into the thrower — so a boss handed
you a free wound per volley. Contact across the racket face sets the angle:
dead centre still goes back at him, which is a placement you earn.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The portrait fit was right, it just ran once and too early: iOS Safari lays the
page out before its toolbars settle, so the box the first fit measured was short
by a strip — and no window resize follows, because the page never scrolls. The
height only came good after a rotate and back, which IS a resize. A
ResizeObserver on the canvas watches the box itself, so every cause is covered
(bars settling, rotation, split view, zoom) without guessing which event or delay
to listen for. It only rewrites the backing store, never the CSS box, so it
cannot feed itself.
Verified both directions: box grows -> logical H 585 -> 671, box back -> 585,
logical aspect matching the box aspect each time.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Am I on the right version?" needs an answer on the device, not in a terminal.
The browser cannot see git and push = deploy leaves no build step to inject a
sha into, so the marker is the served main.js's own Last-Modified — it moves on
every deploy with nothing to remember to bump. Match it with
`git log -1 --format=%cd`.
Cache-buster bumped with it: without that the phone keeps the old main.js and
shows a stale build line, which is the exact thing this is meant to catch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 8px of breathing room under it read as padding, not as breathing room.
SAFE.bottom still lifts the rack clear of the home indicator — that gap is
the one that has to be there. The desktop key hint lived in the band that
just disappeared, so it moves to the empty top-right corner.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The run ended on the second wave every time: the gun fired 6 flat rounds a
second, hit one body each, and every shambler soaked one — while hordeGrow
made each wave longer and denser. Doubled the rate and scaled it with the
wave (14/s at one set banked, 21/s at four), gave a round 0.5m of extra
reach, a wider cone, and enough damage to drop a shambler outright. Raised
the spawn rate to match, so surviving a wave still leaves a standing crowd
on your half — that pressure is the point of it.
Also: he no longer carries a second racket at his hip while holding one
forward (the run frames are the racket-less art), the umpire stopped talking
over himself and saying "remain seated" twice, and a timed freeze now shows
what it is counting down to instead of reading as dead controls.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The triangle was mine. Extending the out-area quad to z=60 put it past camZ
(48), and proj() divides by (camZ - z) — so the scale explodes at the camera
plane and goes negative beyond it, folding the quad's far edge back above the
screen. The quad goes back to z=30; the full-frame clear is what covers the
screen, so it never needed extending in the first place.
The bottom still not being green was a second bug, in the previous commit. The
gradient was set on body, but the ROOT element's background is what paints the
viewport, and index.html gives html a background too — so body's never
propagated and painted only its own box, which has no height because the canvas
is position:fixed. It goes on documentElement now, and html gets an explicit
height, because a gradient is laid out in the root's own box and that box
collapses for the same reason.
Verified with the canvas deliberately ending 40px short: root box fills the
viewport, gradient's lower stop is rgb(41,92,45), the canvas's bottom row is
the same, and the seam is invisible.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
That band at the bottom was not a background mismatch — the canvas was
transparent there. render() never cleared: the stands cover the top and the
out-area quad covers the middle, so it looked complete, but the quad only
reached z=30 and everything below where that projects was see-through. The
page showed through it, which is why it read as a slightly duller green.
The frame is now cleared to pal.out before anything draws, oversized by 8px so
a camera shake cannot expose an edge, and the out-area quad runs to z=60 so the
perspective floor reaches the bottom on any aspect. Verified by sampling alpha
down the bottom third: opaque everywhere, one uniform colour, matching the page
gradient behind it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A phone's rounded display corners clip slivers out of the canvas, and the flat
dark body showed through them as a dark notch where the bottom corners curve in.
The page background is a two-stop gradient now: dark down to the top safe-area
inset, where it sits behind the status bar, then the court's out-area colour for
everything below.
That colour is read live from the palette rather than hardcoded, because it
lerps between the tennis and splatter courts — bright green to dark olive — so
a fixed hex would only match in one mode. Written in resolved pixels rather
than env() so the declaration can't be invalid and drop the background
entirely, and only assigned when the string actually changes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds two sections to HANDOFF. "How to work on this" records the meta-lesson:
every single "this feels odd" note Oliver gave turned out to be a real defect
rather than a tuning preference — seven for seven — so the rule is to measure
before touching a tuning value. It also records that screenshots are not a
verification tool at phone scale (small pixel art is unreadable, and a silent
sprite fallback renders a wiring bug as working code), that drawImage
instrumentation is what settles those, and that a scripted str.replace which
matches nothing is a silent no-op that must be asserted.
"OPEN — leftovers" lists the ten known gaps in priority order: RES 4 unverified
under load on a real phone, the horde melee fix unplayed, the player idle frames
upscaled rather than re-sliced, asymmetric boss wound stages, only three boss
designs, landscape drifting untended, cards without an entrance, the deliberate
chapter-skip, parked step 6, and one audio line to confirm.
Also corrects the module map: there are three runtime-only import cycles now,
not two — state<->zombies joined when sendBoy started reading gore.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The remaining gap was the home-indicator inset being reserved on the canvas
element. Unlike the status bar that is a slim translucent bar, not opaque UI,
so the canvas now runs to the physical bottom edge and the court art fills it —
only the weapon rack and the draw meter lift clear of it, through SAFE.bottom.
The top keeps its element inset, since the scoreboard genuinely has to clear
the status bar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Insetting only the HUD while the canvas stayed full-bleed wasn't what was
asked for. The canvas itself is position:fixed and inset by
env(safe-area-inset-*) now, so nothing renders under the notch, the status bar
or the home indicator, and render.js no longer offsets the HUD as well — doing
both would subtract the notch twice.
The bottom strip was a second bug: the canvas was sized by computing a scale
from innerWidth/innerHeight and setting explicit pixels, so it was laid out
against whatever innerHeight reported at load, before Safari's toolbars had
settled, and stayed short of the bottom forever after. CSS stretches it now
(100vw x 100dvh minus the insets) and fitViewport derives the logical grid from
the box it was actually given, so the aspect always matches exactly — no
letterbox, no distortion.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
viewport-fit=cover was already on, so the canvas was full-bleed and the iPhone
status bar landed directly on the scoreboard. Keeping the canvas full-bleed —
the stands running to the screen edges is the look — and moving the HUD
instead: a hidden probe div carries env(safe-area-inset-*), which env() only
exposes to CSS, render.js converts it to logical canvas px at the same scale
fitViewport just applied, and the scoreboard shifts down while the rack and the
draw meter lift up. Body height also goes to 100dvh so iOS's dynamic toolbars
stop leaving a strip unused at the bottom.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The portrait canvas is adaptive now. A fixed 270x480 is 9:16 while a modern
iPhone is taller, so width bound the fit and ~150 CSS px of screen went unused
at the bottom. Height is derived from the device aspect, and TUNE.horizon rides
with it — proj() places the court in absolute pixels, so leaving horizon fixed
just grew a dead green band between the court and the rack instead of moving
the court down. RES 3 to 4 for the extra pixels: an iPhone gives ~1125 device
px across and we were rendering 810. That is the first knob to turn back down
if the phone stutters.
The swish cooldown scales with how swarmed you are — 1.00s at one body, 0.47 at
four, 0.22 from fourteen. Oliver died on every game-20 horde because the wave
grows with hordeGrow while the panic button stayed a flat second; he chose
scaling the melee over shrinking the horde. The first version measured the
crowd after meleeSwipe had already killed it, so it counted survivors and
barely moved.
Winning a duel while wounded lost the weapon popup entirely: showModal never
clobbers an open modal, so the heal modal opened first and the tin's was
dropped. One popup carries both now.
And the mop boy stays off the court unless a stain actually passes the test
mopRound uses to pick a target. He was walking on, finding nothing reachable,
and walking straight off again.
Tennis untouched: 50 hits / 0 whiffs. 45-game soak clean, all four chapters.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The umpire is situational again. Each line is tied to the thing that justifies
it — a new variant appearing, the court filling, one crossing your baseline, a
game lost, a horde, a changeover — behind one shared cooldown, so several
triggers at once still yield one remark. The random timer pool is gone, the
interval went 26s to 55s, and ump_out_of_order, ump_let and ump_no_appeal are
deleted.
"Visually the ball is not yet at the player but somehow the player already is
hit" was real and measurable. The hostile-ball hit test reused the parry's
hypot(dx, dz*0.8), and weighting depth shrinks it: contact fired while the ball
was still 0.68 m up-court and drawn up around his head. It has its own
unweighted test now, and contact lands at 0.25 m with the ball on his chest.
The parry keeps its weighting — a racket sweep really is wider than it is deep.
On "everything is a tad too small": measured first, because higher-resolution
sprites are only half the answer. At RES 3 the player rasterises into 170
device pixels from a 112px source — upscaled 1.5x — while a far shambler is
downscaled to 78. So near bodies needed resolution and far bodies needed the
camera.
Camera: pushed back and lengthened (camZ 42->48, camF 580->760) to flatten the
perspective so distant bodies grow relative to near ones rather than everything
zooming, camY raised for a steeper look-down, horizon re-centred. Far shambler
26->30px, boss 39->45px, player unchanged, court in the same band.
Sprites: the original sheets were still in the repo at full resolution, so
nothing needed regenerating — IDLE_TARGET_H went to 224 and every sheet was
re-sliced. The sheet-to-output mapping was never recorded, so it was recovered
by matching sliced frames to existing assets by image signature: 58 came back
from sheets, the 29 without a confident match were 2x-upscaled to stay on the
shared scale, since a sprite off that scale renders at half height.
Tennis untouched: 50 hits / 0 whiffs. 45-game soak clean, all four chapters.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five reports from Oliver's second playtest.
The parry was too generous — at 6 m of arm with a 0.34 s window the shot flew
into reach on its own, so any tap once it was vaguely close worked. 3.4 m /
0.2 s / 1.7 m reach now: a tap at 4.5 m is rejected and costs blood, 3.2 m and
in returns. The boss telegraphs in 0.5 s against the machine's 0.9 and throws
harder, because a boss should be worse than furniture.
His three balls were decoration: fanned ±1.9 m across space, so only the middle
one could ever reach you. They are a timed salvo now — three shots 0.42 s apart,
each aimed at where you are at that moment. hurtGrace still caps a whole volley
at one point of damage, which is what it is for.
Every chapter was the same boss. Three designs cycling now, each with its own
sheet, stride, throw pose, wrecked skin and wrecked-throw skin: the
GROUNDSKEEPER, the LINE JUDGE, the CHAIR UMPIRE with his chair strapped on. The
chapter card names them. Designs are data in BOSS_ART, so a fourth is a sheet
plus a row.
Healing announces itself with a first-aid tin and a red cross instead of a 1.4 s
flash. And 14 new umpire lines: ten sarcastic asides on a jittered timer, four
hooked to specific beats. He still has not acknowledged that the match became a
massacre, so he keeps officiating it.
Two traps found and written into the pipeline notes. Sprite loads now carry
?v=SPRV, because python http.server sends no cache headers and the browser
caches 404s — a name added before its PNG exists stays permanently missing for
that browser. And a scripted replace that silently didn't match left drawZombie
on the old hardcoded boss names while everything else moved to BOSS_ART; the
built-in fallback hid it perfectly, all four designs rendering as design 1 with
no error. Assert your rewiring edits matched.
Tennis untouched: 30 tapped points, 50 hits / 0 whiffs. 45-game soak clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six reports from Oliver's first playtest of the boss build. Two mattered.
A boss sprite turned up in the shambler crowd because z_B1.png and z_b1.png
are the same file on a case-insensitive filesystem — writing the boss frames
silently overwrote the 'b' shambler's first two frames, and `ls` only ever
showed the original lowercase name. Restored from HEAD~1, boss frames renamed
z_boss*, and the rule is now in the asset pipeline notes.
The parry was unreachable, which killed the entire new mechanic: P.swingT is
only set in keyPressed's `phase === 'rally'` branch, and splatter sits in
pretoss almost all the time, so every tap aimed at an incoming shot tossed the
serve ball instead of swinging. An incoming shot inside parryArm now owns the
tap in every phase, with its own generous reach and window — this is a phone,
one thumb, against something aimed at you. The three fail cases (too early,
too late, no tap) are what keep it a timing skill. The swish no longer eats
shots: it runs first in keyPressed and was swallowing the very ball the parry
was about to return.
One flame ball killed a 16 hp boss, and that was a bug rather than tuning. A
flame ball is not consumed on contact (full pierce, so it can cut a lane) but a
boss is not removed when hit, so the same ball re-wounded it every frame. Boss
bodies now take a contact cooldown and flame spends itself on them. Chapter 1
costs 4 flame balls or 16 clean smashes, and bossHp goes 10 to 16.
The boss had no wounded state while throwing — correct, the wound skins were
standing poses only. New sheet gives all four damage states their own overarm
pitch. The ball machine's four painted rects are replaced with a real sheet,
idle and firing, keeping only the muzzle glow procedural because the telegraph
has to read at 20px tall. And the player's blood no longer sits on him as a
painted layer: live drip and the screen-edge wash, nothing else.
Tennis untouched: 30 tapped points, 50 hits / 0 whiffs. 45-game soak clean,
all four chapters firing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Steps 4 and 5 of the late-game depth plan.
The ranged channel. A rogue ball machine gets wheeled onto the far court every
four games: it never walks, never crosses your baseline and never scores, so
it is a standing threat you choose when to deal with — the target-priority
decision the game was missing. Its shot is a real arc solved like a serve, not
a flat bolt, so it reads coming in and the swing window has something to meet.
Three outcomes: a swing inside the volley window returns it as your own ball
flying up-court (an extras clone, so "a parry is a weapon" cost nothing to
build), the panic swish bats it away, and letting it reach you costs blood.
The swish path is load-bearing, not politeness: in pretoss the player is
rooted and cannot swing at all.
Contact still kills outright — that stays a separate channel, and killPlayer()
is now the single death path for both causes so the run summary and the best-run
write cannot drift. Health is drawn on the man: procedural spatter at fixed
anchors, a live trickle, and a red wash from the screen edges. No bar. Healing
is winning a duel, which finally gives the duel a reason to be wanted.
Chapter bosses every ten games, with their own sheet — a colossal zombie
groundskeeper hauling a ball hopper, coming apart through arm-off, ribs-out and
headless-still-throwing. Chapter 1 is 10 hp and 2.7 m, chapter 4 is 34 hp and
3.67 m. It outranks the set and duel beats at a clear, because a boss walking
into a standing wave is unreadable. And it fixes the economy: a chapter-4 boss
costs 12 bombs or 100 rounds or 34 side balls against a per-type cap of 9, so
no single weapon can take one and a banked mixed arsenal is mandatory.
The racket zombie is demoted to an occasional opponent, as asked — appearing
every won game is exactly why he read as the boss.
Five silent bugs found on the way, all in HANDOFF: a blast deleted a 34 hp boss
outright (isBoss() exists to stop that class recurring), the machine's hp did
nothing because it fell through to the one-shot fallback, the boss both scored
and fired its card, the machine gated the hand-clear, and a boss died with a
shambler-sized gore burst.
Tennis untouched: 30 tapped points still 50 hits / 0 whiffs. 45-game soak clean
with all four chapters firing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Steps 1-3 of the late-game depth plan, after Oliver's 50+ game playtest.
"Sometimes the racket zombie doesn't seem harmable" was real, not a feeling.
Three of the five rack slots did nothing at all to him and said nothing about
it: TRIPLE side balls skipped him, GUN and horde pellets skipped him, fire
couldn't ignite him. BOMB meanwhile deleted him outright ignoring his hp. All
of it now routes through one wound() with a TUNE.dmg* table, so damage is
proportional to the body and a future 20 hp boss needs no new code. Fractional
damage banks on zb.chip so whole points only come off hp — the wound skins
switch on hp thresholds. His contact radius goes to 0.7 m, because part of
"unharmable" was just missing him. Melee still skips him and that's correct:
he's on the far side of the net and melee can't span it.
The HP bar is gone. It was the only floating UI on a body, it was on screen
for nearly the whole run, and nobody knew what it meant — while the wound
skins already say the same thing in the language every other multi-hit enemy
uses. A one-per-run modal explains what he is instead. Skins and bleed anchors
now read a fraction of hpMax, since his hp scales with the run.
And the plateau: every difficulty dial bottomed out by about game 12, so games
12-50 were arithmetically identical. Body cap, spawn gap, shamble speed,
variant mix, boss hp and horde size all keep climbing now, calibrated to track
the old curve through game 12 rather than replace it. One deliberate exception:
shamble speed was time-based and maxed during game 2, so the early game is
genuinely gentler now — zSpeedPerGame if that reads flabby.
HANDOFF carries the full spec for steps 4 and 5 (ranged attacks with player
damage, then chapter bosses) including the decisions we settled, so the next
session can pick it up cold.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
m.in (0..1 over TUNE.modalIn, 0.34 s) is the entrance clock, ticked inside
update()'s modal early-return — the one thing that animates while the world
is frozen. Icon and title fly in from the right trailing two ghost copies as
a speed streak, the body lines cascade in from the left one after another,
everything on easeOutBack so it overshoots and settles rather than
decelerating politely into place. The dim washes in over the first third. A
descending noise sweep on arrival (swish is parameterised now — same six
lines of Web Audio, opposite reading) and a low pock as it lands.
Every x is rounded: a fractional x on a nearest-neighbour scaled image
re-snaps its pixel columns, which is the boiling that once read as HUD
flicker.
Dismissal is gated on the entrance: while m.in < 1 dismissModal swallows the
gesture but doesn't clear the modal, so a tap already in the air when a popup
appears can't skip it — and swallowing rather than returning false is what
keeps that tap from falling through into a serve.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Oliver vetoed the free movement between points on sight: he never walks
freely during play, so being able to only between points reads as the game
breaking, not as a fix. Reverted to 'rally' only, and the rule is written into
the settled-feel list so it doesn't get re-invented.
The frozen changeover is answered his other way instead — the dead wait too.
`held` in updateZombies stops their advance, their steering and the eat check
for the breather; they still bleed and burn, they just stand there, because
the umpire called time and it is still a tennis match. The path where this
actually bit: losing a duel scheduled on a changeover beat spawns five fresh
shamblers and THEN starts five seconds you have no agency in.
The stuck pose was never about movement — 'run' is the one pose expirePose
doesn't expire, and runT stops advancing outside the block, so a beat that
began mid-stride welded him to a single frame. Reset it and it's fixed for
every phase.
And he could walk off the screen in ordinary play, not just during the beat I
had added: P_BOUNDS.x is 6.5 m, portrait shows about ±4 m at the baseline.
The xVis clamp already existed but only applied during auto-fire; it now
always applies, floored at the sideline so every legal ball stays catchable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>