The hang: I could not reproduce it or pin it on one latch, so this is two
things. A root-cause fix — iOS does not guarantee a touchend when you switch
apps mid-drag, and the held G.aim it leaves behind blocks the uncaught-toss
retoss forever — and a watchdog for whatever else is out there. The watchdog
does not time how long you have been blocked, because a horde blocks you for
fifteen honest seconds; it watches whether the clock that should release you is
moving, and logs the frozen state before letting go.
The duel: past about game 20 all four tins sit at wCap and a won duel handed
over literally nothing. It falls through to the barrel weapons now.
The prize also stops borrowing the praise channel — arriving in the same slot
as PERFECT made it read as another compliment. It gets a card with its own icon.
The turn into zombie mode was unwatchable because the zoom started ON it, and
the curve render applies is zero at t=0, so the camera only arrived half a
second after he had changed. It peaks on the turn now, and the swap lands on a
hitstop, a flash and a burst so it reads as an event rather than a glitch.
Laser down from 0.55 to 0.22 — zap() sums four voices, so its vol was never one
voice's level.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PERFECT fired on almost every serve, and it was not a threshold that had drifted
— it was the wrong test entirely. pullFrac CLAMPS at pullFull, so pullQuality is
exactly 1.0 for any pull of 130 px or more, and 130 px is an ordinary comfortable
thumb drag. "quality > 0.999" therefore meant "you pulled far enough", which is
not an achievement and is why it read as noise.
So hitServe takes `perfect` as its own argument now. The toss ritual keeps
earning it the honest way, on the 45 ms perfectMs window. The slingshot earns it
by STOPPING in a band at the top of the draw (pullFull..pullFull*perfectBand,
130-166 px) — a target you can miss, rather than a ceiling you cannot. The pull
already clicks when it crosses the splatter threshold; it clicks brighter when it
enters the band and goes silent when you pull past, so the window is learnable by
feel instead of being invisible skill.
And the word rotates: PERFECT, TEXTBOOK, IMMACULATE, OH LOVELY, UNPLAYABLE, SHOT
OF THE DAY. Cycled rather than random — the same word twice running is what makes
one read as a stuck HUD element rather than as praise. The umpire has still never
acknowledged the massacre, so a clean shot gets the line it would get at Wimbledon.
Also: the card band grew 72 -> 80 px, because the second sub line ended 4 px off
the bottom edge and looked like it had fallen out. And the quiet face floats as
much as the loud one does, so it gets the same 1 px shadow — the control hints,
the gesture-demo labels, the card subs, the modal body and prompt, the death
summary lines. The scoreboard and the rack keep theirs off: both already sit on
dark panels, and a shadow under 6 px type on a flat fill only smears it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
He died at every chapter boss because he could not move, and there were two
independent reasons for it. The movement block is 'rally'-only — a settled feel
decision the six boss AI styles were written around — and a boss is fought from
'pretoss'. On top of that canAimServe() is true through a boss fight, so his one
finger was already spent on the slingshot: there was no gesture left over that
could have meant "move".
So it is a SIDESTEP, not a walk. One committed step on a cooldown, carrying
invulnerability that beats both damage channels — the chip damage in hurtPlayer
and the instant-death contact test. The rule about walking stands untouched.
The gesture is picked by WHICH WAY the drag leaves the deadzone, decided once and
never revisited for that touch: down-ish is the pull the game already teaches,
sideways is the step. No timing window, no invisible mode, so "one gesture means
one thing" still holds. The finger-steer modes are untouched — stick.aimable is
false in a horde, so the drag is the absolute steer it always was. The apron demo
now alternates PULL DOWN and SWIPE TO DODGE, and Marvin has a page for it.
The rest of the playtest list:
- Bangers is bundled beside Silkscreen, and the register IS the category. The
quiet face keeps the HUD, the rack, the control hints and RESUMING IN; the loud
one takes SET, CHAPTER, EPIC!, PERFECT and every free-floating flash. Floating
text gets one shared y and a drop shadow, so it survives crowd, clay and blood.
- Modals queue instead of being dropped. That was why the trainer and the weapon
had to be crammed into one card after a won duel; the heal gets its own popup
with the x_cross tin over it — a sprite loaded since that merge and drawn by
nothing.
- weaponModal's first-sight gate moved inside the function. The barrels gated
themselves and the tin ladder did not, so four weapons re-explained themselves
every three games while a barrel weapon explained itself once.
- Ten rack slots. Past five it grows a second row upward rather than shrinking
ten tiles under the thumb target; slots 0-4 stay welded to the bottom edge.
- The boss win holds for its own 3.6s and says EPIC!, with no GAME n NEXT line —
the bar under the scoreboard already prints that permanently. That bar went
from 6px to 7px, which is the smallest type in the game becoming words.
- The turn punches the camera in on him and the ground shakes harder at each beat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Oliver's playtest of the barrel weapons. Four notes, all four real defects.
"The bigger guys were way too easy to kill at the beginning." A bug, and an old
one. The ball-vs-body chain handled boss, g/m, s, f and a/b, and everything NOT
named in it fell through to "splat-quality kills outright" -- so the STEWARD has
died to one serve since the day he shipped with stewardHp 5 and a comment calling
it a brute's worth of hp, and the three new bodies inherited that the moment they
existed. Pellets and melee always read hp; only the ball skipped it. One generic
hpMax > 1 branch now, and measured after: shambler 2, steward 5, brute 5, ball
boy 3, bouncer 5, roller man 7 -- each exactly its hp.
"I have seen all of them by round 10." The new three move 3/8/15 -> 5/12/20. The
original four keep their gates: that curve was already tuned through game 12.
"The laser fired only when I tapped, that was confusing." The whole tap-per-round
kind is gone. A barrel weapon is now either the normal ball gesture made
spectacular or something that fires itself -- nothing waits for a tap.
- laser is a serve, and the RAY is the weapon: no ball anywhere in it. The
preview IS the wedge, it leaves the RACKET rather than lying on the grass, the
release erases the plane along its own path behind a bright front, and the
edges are hairlines instead of the heaviest lines on the court.
- beacon and rocket are LOBBED bursts -- `arc` turns a flat pellet into a thrown
one, solved onto the nearest body ahead, and landing is itself a hit so a
rocket that misses still goes off where it lands. The beacon changes colour
every ball and drags that colour behind it.
- flamer got "way more pronounced": 30 rounds/s, a ~10 m cone, embers.
"I got the laser almost every time." A flat 1-in-4 over four items does not feel
uniform. BARREL_WEAPONS carries weight and a `from` gate now, never repeats twice
running, and the beacon is what you meet first. Measured: laser 16-28%, rocket
unlocked at game 12 so it is genuinely a late find.
And the barrel keeps its pink halo -- that was always procedural, painted by
drawBarrel, and never went near the chroma key. Three rings and a pool on the
lawn now, because one flat disc read as a smudge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rack had five hardcoded slots where the index WAS the weapon, spelled out in
five places: SLOTS, slotStock, WDESC, drawRack's three parallel arrays,
deliverTin's w+1, and bare wsel === 1|2|3 / G.weapon === 4 tests across five
files. config.WEAPONS is the one table now, G.rack holds up to four {id, n} in
the order you GATHERED them, and slot 0 stays BALL. Everything reads through
slotId/slotStock/slotEntry, so nothing else knows how the rack is stored.
A fifth weapon is always allowed, per Oliver: it rides slot 0 as G.temp until
spent, then slot 0 goes back to the infinite ball. A barrel arms on the spot and
is spent quickly, so refusing it would mean a barrel you shot did nothing. And a
spent weapon LEAVES its slot -- with gathered slots, an exhausted weapon holding
one is a quarter of the rack you can never pick up into. (The old rack kept its
empties dimmed, which was right when a slot meant "BOMB goes here".)
Four new weapons, and they are all the ONE existing pellet loop. firePellet(w)
stamps the WEAPONS row onto the round -- r, dmg, hp, pierce, boom, ignite, col,
speed, life -- so the beacon cannon, flamethrower, laser and rocket needed no new
array and no new update; the break after the first body was the only thing making
a pellet single-target. All four go through the same isBoss branch, which is why
none can silently no-op on a boss the way three of five once did. Measured:
gun/beacon/laser/rocket reach past 16 m, the flamer is a ~6 m cone that ignites.
G.mgT stays the mode timer for all of them, so the eighteen !(G.mgT > 0) guards
across five files did not have to change. kind:'burst' auto-fires at its own rate
for len seconds; kind:'shot' holds the same braced stance and fires one per tap.
The barrels reuse the ball machine's shape: a rolling, shootable non-body. All
three of the registrations that fail silently are done -- blocksClear (loot must
not gate the hand-clear), the contact guard (furniture cannot eat you), and
hitR/GORE_MUL/zombieH. It scores nothing.
The pink had to become procedural. The chroma key deletes every pixel where red
and blue both beat green by 40, so a magenta drum and a magenta laser pistol both
came back as hollow outlines -- 19% and 15% solid against 50-56% for the icons in
other colours. The drum is grey steel with hazard chevrons and every bit of its
pink is the halo drawBarrel paints; the laser went electric blue. Both are solid
now (92% and 58%), and the rule is written down in HANDOFF.
tinTriples 3 -> 10 as asked, and the four dead TUNE ammo constants are gone --
nothing read them once the table landed, which is a panel value you can tune all
day for no effect.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The lane used to come from the pull's own direction, so aiming up-court was
always a downward drag. Every shot ended lower than it began, the next touch
starts where the last one ended, and over a horde the thumb walks itself into
the iOS bottom edge — where a pull down is Reachability and the system takes
the gesture out from under you. A web page cannot refuse that.
So touch-down picks the lane now (a ray from his chest through the thumb) and
the pull after it is power and nothing else, in whatever direction there is
room for. Aiming is what drags the thumb UP the screen, because that is where
the shamblers are.
The cost, and the thing to feel: the lane locks when the thumb lands. You can
no longer sweep the aim while drawn.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The slingshot pulled back, which meant every shot ended with the thumb lower
than it started — and the next touch begins where the last one ended. Over a
horde it walks itself into the bottom edge strip, where a pull down is
Reachability and iOS takes the gesture. A web page cannot refuse that.
So the drag now points AT the target: the thumb ends high and drifts up-screen,
away from the edge. It also matches the flick-swing, which always aimed this way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The menu stage was the same logo on the same backdrop with two buttons, so it
read as the title screen failing to advance. The buttons move onto the title
screen in place of the control list, which is the tutorial's job now.
Death no longer reloads the page. The reload was an honest full reset, but it
also tore down the AudioContext — so the theme restarted from silence every
time you died, when it should carry straight on from the death summary into the
title screen. restartRun() puts G, P, O, ball and score back from a
structuredClone taken at module load (all plain data, so the clone is the whole
job and a field added later is covered for free), and the two modules with
timers of their own reset themselves: every zombies array plus spawnT/moanT/
mgTick, and input's held keys and half-finished drags, which would otherwise be
inherited by the next run's first frame.
The overlay is hidden rather than removed so it can come straight back, which
needs its own [hidden] rule to beat the display:grid on #intro.
The theme starts on the first tap and never restarts: loopBed's beds.has guard
already makes every later menuMusic() a no-op.
Verified: one background-music fetch across launch -> run -> death -> title ->
second run, and a full second run through the transition after the in-place
reset.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The three foundations first, since everything else leans on them:
- G.modal is the only thing that stops the world. update() early-returns on it
so no zombie steps, no ball flight, no spawn timers run while a popup is on
screen — you can read it without being eaten. Dismissal swallows the gesture;
the pointerdown dismisser is registered only on pointer:fine, because on touch
pointerdown beats touchstart and a shared handler cleared the modal and then
served with the same thumb.
- UI in config.js is the accent palette. The arcade primaries were shouting over
the artwork; the gore reds deliberately stay loud.
- Every canvas font string now goes through F(). Bare `monospace` resolved to
Courier — a slab serif — which was the "generic serif" bug, not taste.
Then: weapons are only ever WON. deliverTin() has exactly one caller now
(resolveInterlude('P')); the old game schedule calls the DUEL instead, fired off
proceedAfterClear() where the court is guaranteed empty. Four popups (splatter
tutorial with a generated illustration, first duel, weapon received, horde),
wCap 9 with the tin amounts tunable, a 5-hp brute that comes apart over four
wound skins, fire that ignites the neighbours and chains outward, a three-stage
death where you become the biggest gore mound on the court, and a 7s changeover
with a ball boy who finally has a mop.
New art: assets/logo.png, x_howto, z_gw2-4, x_boymop1-4, plus assets/_key.py for
standalone art (chroma-key + crop, no frame splitting, no forced sprite height).
Benchmarks re-run and holding: CPU returns 23/24 serves, 30 human-tapped points
= 50 hits / 0 whiffs, 26 simulated games with no exceptions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things from the phone test.
The drag meant two different things depending on invisible state: aiming from
pretoss, but walking the player around while the last ball was still in the
air. That "mode between the hits" read as a bug, so it's gone — canAimServe now
covers every phase of splatter, and firing over a live ball is allowed again
(the old one flies on as a ballistic clone). That was the mid-rally re-toss
that used to be the spam vector, and it's safe now because the brake moved into
the pull: a rhythm of lazy 30px pulls fires 100 shots in 30s and kills NOTHING
(the court fills up instead), while 85 committed full pulls kill 11.
hitServe never had doHit's net-clearance loop, so every shot at a shambler
hugging the tape died in the net — not balance, a missing loop. Both paths now
trade pace for arc until the ball is genuinely over, and the ceiling goes 8 ->
12 tries because a target right behind the net needs a near-vertical drop
(measured: clears the tape by 0.20m and connects). The trade is real: the loft
arrives slower and the walk-lead was computed for the original pace, so a lob
is honestly the less reliable shot.
And the camera now rises and leans back a little while the slingshot is drawn.
That steeper look is what makes the gap between the net and the bodies behind
it readable — the shot that used to be blind. Eased once per frame before
anything projects, since one sheared frame would be visible.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ball economy made it harder in the wrong way: waiting for a ballboy is
friction, not difficulty, and walking with the finger was one gesture too many
for anyone to discover. Both are out — a spent ball hands you a fresh one
again, and the thumb only ever does the slingshot.
The brake moves into the gesture instead. The pull's LENGTH is now the shot's
power and nothing else: under TUNE.zSplatQ it merely staggers, at full draw
it's quality 1.0 with the PERFECT flash and hitstop. A lazy rhythm of short
pulls can't kill anything, so tempo stops being a strategy.
The pull's DIRECTION is a ray, not a landing point, which is the ergonomic fix
for having to drag off the bottom of the phone: distance to the target no
longer costs drag length. The first shambler standing in the lane eats the
ball, whether it's 5 or 21 metres away, and the cone widens with range so
distant lanes stay pickable. Full power now sits at 95px of travel.
Also gone: the free predictive auto-lock on a directionless aim (zombieTarget
returns null with no direction, and a bare hold no longer fires at all). Melee
goes back to cleaving the cluster — it's the panic button — with the 1s
cooldown left in as the brake. The reticle reads power throughout: cool blue
only staggers, hot red kills, the drawn band grows behind you, and the pull
clicks the moment it crosses the kill threshold.
Verified by simulated touch gestures: 10px cancels, 35px = 0.31 (stagger),
60px = 0.60, 95px = 1.00; the same 60px pull reaches shamblers at 5m, 21m and
diagonally; 22 serves in 30s, so the pace is back.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The slingshot serve didn't fix the gameplay because four things were free at
once: pure-hold auto-target with predictive lead, a hardcoded 0.62 serve
quality, infinite ammo with an instant mid-rally re-toss, and a melee swish on
every single tap that cleaved the whole cluster. Perfecting the pretoss/toss
rhythm beat the game; no skill required.
The ball is scarce now. A spent ball LIES where it died (G.loose, ringed on
court) and there is no serve until it's back in your hand: walk it down
(instant, and the only reason to leave the baseline), let a boy scoop it, wait
out TUNE.looseMax, or watch a shambler swallow it — the one real punishment.
Fire rate is a positioning decision instead of a thumb-tempo one.
Also: the far backstop rebounds a ball with pace left, still lethal on the way
home and volleyable (trySwing lets P re-hit their own ball in splatter); melee
drops to nearest-one-only on a 1s cooldown; and the mid-rally re-toss is gone.
With no ball in hand the thumb-drag steers your legs instead of the reticle —
without that, walking your ball down is impossible on touch.
TUNE.looseMax is the fire-rate knob. Measured: ~4s per serve standing still,
~0.4s if you go and get it. 600 taps in 10s now yield exactly one serve.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The touch serve drag now inverts: pull toward yourself to launch up-court.
The first pull past the deadzone starts the real toss animation (serveToss),
dragServe fires straight from the toss phase without banishing its own fresh
toss, and a held aim keeps the toss airborne instead of dropping. Render shows
an Angry-Birds dotted trajectory racket->target (red = zombie locked). Two-stage
tap serve, mid-rally re-toss/banish, and keyboard serve+arrow-aim unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Stand at the baseline: in splatter the player is pinned to the baseline
(a turret, side-to-side only) — the duel is exempt so it keeps free depth.
- Zombie 1:1 duel now plays like real tennis: no free-aim flick, no drag
reticle; lane aim + the toss-then-hit serve (gate on !G.interlude in
doHit/hitServe/keyPressed/canAimServe).
- Rapid serves: the two-stage reticle serve now fires mid-rally too — a ball
still in flight is banished to a ballistic clone so the next serve launches
at once. Serve as fast as you can drag-release.
- Wounded skins for multi-hit foes: brute (arm off) and racket boss (arm off,
then headless) swap by remaining hp, with a graceful fallback to the healthy
sprite. The headless boss is drawn a head shorter so the shoulders line up.
- Dynamic gore: the blood is NOT painted in — open wounds gush live particles
every frame (arm stumps + a neck geyser on the headless boss), absurdly, and
bounded so it self-regulates.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tins now accumulate on a courtside rack (cap 3) instead of replacing each other; a PLAIN slot banks specials for later, and GUN is a charge you pop for an auto-fire burst. Tap the rack or press 1-5 to choose what your serve throws. A fresh delivery auto-equips but never overrides a deliberate PLAIN, and the 1:1 sudden-death duel forces plain balls.
HUD gets real art: generated pixel-art weapon icons, an ornate Wimbledon scoreboard PNG backing the live text (no more code-drawn boxes on top), and a bundled Silkscreen pixel font replacing the system serif. Rack tiles get padding, count-over-label alignment, and an infinity marker on the ball.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
THE FREEZE (root cause found): the game-winning kill fires INSIDE the zombie
update loop — killZombie -> splatPoint -> gameOver -> sweepCourt guts the
zombies array mid-iteration, the loop reads zombies[i] === undefined, throws,
and the un-rescheduled RAF chain dies: a permanent hard freeze. Crowded courts
(later games, brutes) make the mid-loop game-winner likelier — hence 'freezes
around game 3 when the fat zombies arrive'. Fixed at the root (null guards in
every loop whose body can cascade into a sweep: zombie walk loop, meleeSwipe,
explode, pellet + extras inner loops) AND made frame() reschedule RAF FIRST so
no single bad frame can ever kill the loop again.
Aim awareness: the reticle now appears the moment the thumb lands (mag 0 =
auto-target the most advanced zombie, red lock immediately). A pure HOLD
(>250ms, no drag) releases into the auto-target serve; dragging aims as
before; the serve prompt switches to 'RELEASE TO SERVE / RELEASE FIRES THE
BOMB - DRAG BACK TO CANCEL' while aiming.
Duel presentation: the interlude now opens with a 2.4s intro beat — serving
blocked, centered card (SUDDEN DEATH / ONE POINT - THE LINES ARE LIVE / WIN
IT FOR THE NEXT TIN), and the palette lerps back to the TENNIS colors for
the duration of the duel (duelLerp, 0.8s each way) — order visibly restored,
then the rot returns.
Machine-gun steering: absolute now — the finger x IS the player x, zero lag
(was sign-quantized velocity). Clamped to what the camera can see
((W/2-14)/proj-scale — portrait's world bounds are wider than its viewport),
keyboard steering clamped the same during auto-fire.
Verified: the exact pre-fix crash scenario (game-winning kill mid-loop,
9 zombies + brute) survives and sweeps clean; intro blocks taps then the
duel plays; hold-release fired at the auto-lock and won a tin end-to-end;
screenshot shows tennis-green court + centered card + GAME 2; MG jumps
P.x to the finger in one frame, clamped; tennis 12/12; no console errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Flick-to-aim was aim-by-faith — you learned where you aimed after the ball
was gone. Now, at a serveable pretoss, touching and dragging shows a LIVE
reticle (same mapping: direction + length = landing point); release serves
there. The magnet is finally visible: within zSnap the ring turns red and
rides the locked zombie (plus a marker over its head) — you see the lock
BEFORE you commit the tin.
- Context locked at touchstart: a movement drag started mid-rally never
morphs into a serve when the ball dies (sub-300ms releases still count
as the rapid flick re-serve — the Phase 1 cadence survives)
- Drag back home = cancel: no serve, the tin stays in stock
- Aimed (drag) serves commit specials at fixed 0.62 quality; the tap-toss
ritual keeps the PERFECT-timing power ceiling and the stock — placement
vs power is now a real choice
- Rally / horde / machine gun untouched: drag steers, flick swings
- Serve prompt: 'TAP TO SERVE - HOLD + DRAG TO AIM' / 'AIMED SERVES FIRE
THE BOMB - TAP KEEPS IT'
Verified with synthetic touch sequences: drag arms the reticle without
serving and it tracks the thumb; release serves at the marker with the bomb
committed; cancel keeps the tin; rally-started drags never morph; locked
reticle renders red on the zombie (screenshot); tennis 12/12; no console
errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Oliver's six complaints, addressed:
1. Opponent never came — a racket zombie now spawns on EVERY game you win
(if none alive); endHorde no longer double-spawns
2. No wave-cleared feeling — sweepCourt: at game end the surviving wave
collapses, every game gets a real beat (gameBeatT 1.6 / changeover 4.5),
and 'GAME N' announces the next wave when play re-arms
3. Serve-posture lockup during machine gun — root cause: any 'over' beat
expiring into setupPoint() -> pretoss during auto-fire (no movement outside
rally, no serving during horde/GUN). The over-expiry now returns auto-fire
modes to 'rally', never the serve loop
4. Forced bomb spending — specials commit ONLY on deliberate serves: flick
(or arrow-aimed keyboard serve) fires the tin, plain tap keeps it; serve
prompt teaches it ('FLICK FIRES THE BOMB - TAP KEEPS IT')
5. Auto-aim — free-aim: the flick vector maps to a landing point (length =
depth, up to 22m), zombies within zSnap 1.4 magnet the shot, otherwise the
ball lands exactly where flicked; taps keep the old auto-target assist;
flicks now also work at the toss apex
6. Zombies walking through you — option (a): touch is honest. eatenR 0.9
(brute +0.2) matches the visual sprite overlap
Phase 13 — SUDDEN DEATH interlude: court clear + un-dueled racket zombie +
about to serve -> 'Quiet, please. Play will resume.' / 'Sudden death.' One
point under REAL tennis rules (line calls live, the boss plays every ball,
spawns paused). Win (line call, double bounce, or regicide by any weapon) =
the next tin delivered on the spot; lose (out, or its return passes you) =
a game on their scoreboard + an instant 5-shambler surge. One duel per
opponent. New lines: play_resume.m4a, sudden_death.m4a.
Verified in browser: horde 'over' beat -> rally (lockup dead); game win ->
sweep + boss + beat -> SUDDEN DEATH chains naturally; duel lose path (GAME.
THEM., surge 5, scoreboard), no re-duel, fresh-boss duel, bomb regicide ->
tin on the spot; tap serve keeps stock, real touch-flick consumes + lands
at the flicked point, magnet snaps when near; eaten at 0.85m; tennis 11/12;
no console errors. PLAN.md/HANDOFF updated: plan v2 complete.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
game.js (830 lines) -> 9 modules, native ES modules so still zero build step:
dom canvas handles (imports nothing; keeps cycles harmless)
config TUNE (live-tunable) + structural constants
state all mutable game state (G) + setupPoint/sendBoy
audio sfx synthesis, recorded umpire voice, reverb, speech fallback
input keyboard + touch, exports axes and takeFlickAim()
gameplay ballistics, swings, serve, rules, AI, update loop
render camera, viewport, sprites, crowd, all drawing
panel T tuning UI
main boot, loop, intro, window.T test hooks
Deduped into named constants: bounce tempo (was in 3 places), contact
height band, intercept zone depth, home positions, player bounds, toss
params, shot depths, pose hold time, sprite scale, actor heights. Also
extracted drawShadow/drawBall/expirePose from repeated code.
One intentional import cycle (input <-> gameplay), safe: both sides only
call functions at runtime, never during module evaluation.
Regression-verified in browser sims: point cycle, CPU returns 11/12,
assist contract 8/8, corner-winner gradient intact (0/12 perfect corners
returned, 10/12 center balls returned).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>