The racket kept changing hands because the art disagreed with itself: p_idle, p_bounce and p_toss held it in his LEFT hand while p_smash and p_swing used his right, so every new frame copied whichever reference it was handed. The idle, the bounce pair, the toss and the four wind-up frames are all regenerated with the side named — drawn from behind, his right hand is the right-hand side of the image, and only the racket HEAD crosses behind his back. What separates the four wind-up stages is how far the racket has fallen, and that needed the racket head pinned to a body landmark per frame — above his head, beside his ear, between his shoulder blades, at the back of his thighs. Described as "further into it each time" it drew four near-identical poses twice running. They are wired late in the pull, Oliver's call: frame 1 owns the first half and the three coiled frames are packed into the top half. assets/_derive.py is deleted. Fixing generated art by mangling it afterwards is always worse than regenerating; three guards in _regen.py turn the bad rolls that used to install silently into automatic re-rolls — a portrait reference canvas (the generator answered with a 2x2 grid), a non-magenta background (a green sheet keyed away four racket heads), and a mirrored frame. The bounce pair is one drawing from one sheet now, differing only in the free hand, so BOUNCE_IDLE moved to his left side and was re-measured off the sprite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
78 KiB
HANDOFF — Splatter Tennis
This file is the only planning document. PLAN.md and POLISH-PLAN.md are deleted —
everything they specified is shipped, and what still matters from them is recorded below. For the
long history, ~/.claude/projects/.../memory/splatter-tennis-history.md is auto-loaded; for the
detail of any individual change, read the git log.
Last updated 2026-07-31 (serve wind-up pass). Start with "How to work on this", "OPEN — leftovers" and "LATER — shipping it" (all three just above the CLOSED sections) — the first tells you how Oliver's feedback should be handled, the second is everything known to be outstanding in the game, the third is the demo/store/campaign plan, which is decided but entirely unbuilt. All four playtest rounds are closed below them, newest first. Awaiting his next phone playtest.
What this is
SNES-style pseudo-3D tennis (Super Tennis reference) that transitions into a zombie splatter game while keeping every tennis aesthetic (umpire voice, 15/30/40 scoring, ball boys, applause). Zero-dependency vanilla JS, native ES modules, no build step. Mobile portrait one-handed play is the primary target.
- Run locally:
preview_startnamesplatter-tennis(python http.server :8641,.claude/launch.json) - Deploy: push to
git@gitea.wuwi.one:wuwi/splatter-tennis.git(main) → Coolify webhook builds the Dockerfile (nginx, no-cache). Oliver tests on his phone at splatter-tennis.wuwi.one ~30s after a push. Bumpmain.js?v=Nin index.html every time or phones keep the old build. The Dockerfile must also COPY any new top-level file (it currently shipsindex.html,manifest.webmanifest,*.js,assets/). - Oliver edits files himself (tuning values, sometimes sprites). ALWAYS Read current file state before editing; never assume your last version is what is on disk.
Module map (all in repo root)
config.js TUNE (live T panel) + structural constants + UI palette · state.js the G object +
P/O/ball/score + setupPoint/sendBoy · gameplay.js ballistics/swings/serve/rules/AI/update() +
transition + splatPoint + ball-boy routines · zombies.js zombie/gore/particle state +
updateZombies · render.js camera/palette-lerp/sprites/all drawing · input.js keys/touch/flick ·
audio.js sfx + recorded umpire (assets/audio/*, macOS say -v Daniel) · panel.js the T panel ·
main.js boot, loop, umpire-tap cheat, window.T test surface.
THREE runtime-only import cycles (documented in headers): input↔gameplay, zombies↔gameplay,
and state↔zombies (sendBoy reads gore to decide whether a mopping round is worth walking out for).
All three are safe for the same reason: the imported binding is only ever touched inside a function at
runtime, never during module evaluation.
How the game flows
Tennis → win a game → transition (G.mode='transition', timeline on G.transT) → splatter.
In splatter you serve at zombies; ball.pow ≥ TUNE.zSplatQ splatters, below that it staggers.
Tennis scoring keeps running. TUNE.killsPerPoint kills = one point; games/sets accumulate;
6 games = a set = a horde (racket auto-fires, finger steers you absolutely).
Odd games end in a changeover breather. A won game leaves the wave standing — you must
clear it by hand (G.clearing) before play advances (proceedAfterClear).
Every 10 games a CHAPTER BOSS walks on (G.bossFight) — it clears the stage, blocks all spawns,
and volleys at you. Every 4 games a rogue ball machine is wheeled out. Both attack the second
damage channel: shots[] chip G.hp, which only a won duel heals. Contact is still instant death.
Weapons are only ever won, in the sudden-death duel (G.interlude) against the racket
zombie (now an occasional opponent, TUNE.racketEvery, not the every-game fixture he used to be). Getting caught = G.mode='eaten' = permadeath, and the run summary is the whole game.
Architecture & invariants — know these before touching anything
G.modalis the only thing that stops the world.update()early-returns on it (after flash/card decay), so no zombie steps, no ball flight, no spawn timers — onlyidleTticks for the blink.showModal/dismissModalin gameplay.js;drawModalis the LAST thing render draws. Dismissal must swallow the gesture: input.jstouchstartreturns on it, keydown returns on it, and thepointerdowndismisser is registered only onpointer: fine— on touch, pointerdown fires before touchstart, so a shared handler cleared the modal and let the touchstart underneath serve.G.seen.<key>gates first-time-only modals.m.in(0..1 overTUNE.modalIn) is the arcade entrance and the ONE thing that animates behind the freeze — it is ticked inside that same early-return, so it advances while nothing else does. Icon and title fly in from the right (with a two-ghost speed streak), body lines cascade from the left, all oneaseOutBack; every x isMath.rounded, because a fractional x on a nearest-neighbour scaled image re-snaps its pixel columns (the boiling that once read as HUD flicker). A modal with noinfield draws fully landed, so older/test call sites still work. Whilem.in < 1,dismissModalreturns true without dismissing — it swallows the gesture but won't let a panic tap that was already mid-air skip a popup nobody has read. Returning false there would drop that tap through into a serve instead.- The difficulty director lives in
updateZombies's spawn block +spawnZombie+pickVariant, and every dial is keyed onG.runGames. It is the thing that broke first: as shipped, ALL of it bottomed out by about game 12 (body cap pinned at 14, spawn gap floored at 1.0 s, variant mix frozen at exactly 0.42 advanced, shamble speed maxed after 105 seconds of splatter, boss stuck at 3 hp, every horde identical). Oliver played 50+ games and reported the plateau; games 12-50 were arithmetically the same game. All six now keep climbing — see the TUNE block marked THE DIFFICULTY DIRECTOR. Calibration rule: the curve must track the old values through ~game 12 and only then extend, or you have silently nerfed the part of the game that was already fine. The raw dials do run out around game 25 by design; bosses and ranged attacks (steps 4-5) are what carry escalation past that, not more bodies. wound(zb, amount, fx)in zombies.js is the ONE place damage is applied to a multi-hit body. Fractional amounts are legal (a pellet chipsTUNE.dmgPellet= 0.34 of a point); they bank onzb.chipand only WHOLE points come offzb.hp, because the wound skins andbleed()anchors switch on hp thresholds and a fractional hp would never match one. It returns true when the body should die and the CALLER kills it —splatter()when the kill should get the multi-kill fanfare,killZombie()for a quiet one — after which the caller must stop touchingzb, becausekillZombiesplices the array. A boss with 20 hp therefore needs no new damage code at all.- Boss damage state is read as a FRACTION of
zb.hpMax, never against literal hp numbers.spawnRacketZombiescales hp withTUNE.racketHpPerGameand storeshpMax. Two places switch on it and they MUST stay in step:drawZombie's skins (<= 0.34headlessz_cw2,<= 0.67torn-armz_cw1) andbleed()'s wound anchors. The old hardcodedhp === 2left a scaled boss looking pristine until the killing blow. - The racket boss has NO health bar, deliberately. It was the only floating UI on a body anywhere,
it was on screen for nearly the whole run (a boss respawns every game you win), and players did not
know what it meant. The wound skins already encode the same information in the language every other
multi-hit enemy uses. A one-per-run modal (
G.seen.racket, fired fromspawnRacketZombie) explains what he is instead. Do not re-add a bar; give a new boss more wound stages. - There are TWO damage channels and they must never be merged. Contact with a body is still
instant permadeath (
killPlayer,G.mode='eaten'); ranged attacks chipG.hp(hurtPlayer). Oliver settled this explicitly — two threats that demand two different responses is the depth, and making contact chip instead of kill would lose the dread the whole game runs on.killPlayer()is the single death path for BOTH causes (extracted for exactly that reason: the run summary, the best-run localStorage write and the death animation exist once).TUNE.hurtGraceis what stops a three-shot volley deleting you in one frame. - The player's health is drawn ON HIM and nowhere else. No bar, no meter, no HUD number — same
rule that removed the boss's bar.
drawPlayerWoundspaints procedural spatter at FIXED anchors (fixed so it cannot crawl frame to frame),bleedPlayer()adds the live trickle, andG.hurtFlashwashes the screen edges inward on impact. Blood is never painted into the player sprite: that is a settled feel decision, which is why this is an overlay and not a set of bloodied-kit PNGs. shots[]in zombies.js is the HOSTILE ball array — the ball machine's shots and the chapter boss's volleys. Deliberately separate fromball(there is only ever one of those and it is yours) and fromextras(also yours, and they kill zombies). Three outcomes, in this order: a swing inside the volley window RETURNS it — as anextrasclone atTUNE.parryPow, so "a parry is a real weapon" costs nothing to build;meleeSwipemerely bats it away; reaching you unanswered costs hp. The swish path is not optional politeness — inpretossthe player is rooted and cannot swing at all, so without it a machine winding up while you lined up a serve was an unanswerable hit.isBoss(zb)('r'or'B') marks bodies that takewound()damage from everything instead of being deleted by it. The brute'g'is deliberately NOT one: melee/bomb/fire one-shotting a brute is a settled "power exception". Every weapon path checks it — miss one and that weapon silently no-ops or one-shots a 34 hp boss. This has already been the bug twice.- The chapter boss scores NOTHING when it dies. Its reward is the chapter card plus the parked
game beat resuming. Letting it bank a kill meant
splatPoint's game-over branch andendBossFightboth decided "what happens next" in the same frame and clobbered each other. blocksCleardecides which bodies gate the hand-clear after a won game: NOT the racket zombie (it keeps its residence) and NOT the ball machine (furniture — a standing threat you deal with when you choose). Making the machine gate progression turned "decide what to prioritise" into a chore every four games.- The parry is bound to the TAP, in every phase, and that is load-bearing.
keyPressedchecksshotsfor anything withinTUNE.parryArmFIRST and turns the tap into a swing if so. Before that, the parry sat behind thephase === 'rally'branch at the bottom ofkeyPressed— and splatter is inpretossnearly all the time, so a tap aimed at an incoming shot tossed the serve ball instead and the return was unreachable in practice. Oliver could not land one in a whole session.TUNE.parryReach/parryWindoware deliberately much more generous than a normal volley (2.4 m / 0.34 s), because this is a phone, one thumb, against something aimed at you. The fail cases still exist and are what keep it a timing skill: tap before the shot is insideparryArm, tap after it has passed, or don't tap.parryWindowmust stay <=swingWindup + swingWindow * VOLLEY.window(0.348 s) or update()'s swing clock expires before the window closes.meleeSwipedeliberately does NOT touchshots: it runs first inkeyPressed, so it used to eat the very shot the parry was about to return. zb.hitT(TUNE.bossHitCool) stops a LINGERING ball multi-wounding a boss. A flame ball has full pierce — it is not consumed on contact, so it can cut a lane — but a boss is not removed when hit, so the same ball sat inside its radius and re-wounded it every frame. One flame ball killed a 16 hp chapter boss. Flame now also spends itself (lastHit = 'X') on a boss specifically. Any new weapon that is not consumed on contact must sethitTtoo.- No blood is painted onto the player. Oliver: "the player blood looks odd, appears as a painted
layer on top". His health reads as
bleedPlayer()'s live drip plus the screen-edge wash on impact, and nothing else. This is the same settled rule that governs the zombies — blood is never painted into a sprite — and adrawPlayerWoundsoverlay broke it. - Boss designs are DATA:
BOSS_ARTin config.js, one row per chapter, cycling. Each row is{ name, walk: [2], wounds: [...], throws: [...] };spawnChapterBossstamps the row onto the body aszb.artanddrawZombiereads only that. Damage picks a wound stage as a fraction ofhpMaxspread over however many stages the design HAS, andthrowsis index-clamped, so a design with fewer throw frames than wound stages just reuses its last one. Adding a boss is a sheet plus a row — never a change to drawZombie. Missing/unloaded sprites degrade to design 1 rather than vanishing, which is convenient and also means a wiring bug looks exactly like working code (see pipeline note 7). - Sprites are 2x (
SPRITE_H224,_slice.pyIDLE_TARGET_H224) and EVERY scale-critical sprite must share that scale.RESis 3, so the player at the baseline rasterises into ~170 DEVICE pixels — a 112px source was being UPSCALED 1.5x, which is why near bodies looked soft. Sprites that go throughdrawChar,drawBoy,drawMachineor the wound-stageh *= img.height / SPRITE_Hrule are all measured againstSPRITE_H, so a sprite left at the old scale renders at HALF height. That is why the remaster 2x-upscaled the ~29 frames it could not re-slice rather than leaving them. Sprites drawn through plaindrawStatic(name, x, z, hMeters)only care about ASPECT, so their resolution is free. Heights above 224 are deliberate, not bugs: a smash or toss pose SHOULD draw taller than idle (p_smashis 322), and that ratio is exactly whatSPRITE_Hexists to express. - The umpire's asides are SITUATIONAL, never a timer pool.
aside(name)in gameplay.js is the only entry point and enforces one shared cooldown (TUNE.asideEvery), so several triggers in the same moment still yield one remark. The first version drew from a bag on a timer and Oliver's verdict was "too much and not situation aware.. they seem random". Each line is now tied to the thing that justifies it — a new variant appearing, the court filling up, one crossing your baseline, a game lost, a tin delivered, a horde, a changeover, a machine, a boss, the trainer, taking a hit. - The portrait viewport ADAPTS to the device, and
TUNE.horizonmust ride with it.fitViewportpicksh = 270 * clamp(innerHeight/innerWidth, 1.6, 2.3)— a fixed 270x480 is 9:16 while a modern iPhone is ~0.46, so width bound the fit and ~150 CSS px of screen went unused.proj()places the court in ABSOLUTE logical px offhorizon, and the rack/meter hang offH - ..., so extra height lands entirely in the apron between them: leavinghorizonfixed just grew a dead green band. It is computed as45 + (h - 480) * 0.72for that reason.RESis 4 (was 3) so an iPhone's ~1125 device px across is nearly 1:1 with our 1080 —RESis the first knob to turn down if the phone ever stutters, it is the whole fill-rate budget. Note#hint(the desktop key legend) is alreadydisplay:noneonpointer: coarse; it only overlaps the rack in the desktop test pane, which is not a real bug. - The swish cooldown scales with how swarmed you are (
meleeCooldown()in gameplay.js):meleeCool / (1 + (near-1) * meleeCrowd), floored atmeleeCoolMin, counting bodies withinmeleeCrowdR. 1 body 1.00 s, 4 → 0.47, 8 → 0.27, 14+ → 0.22. Oliver died on every game-20 horde because the wave grows withhordeGrowwhile the panic button was a flat 1 s; he chose scaling the melee over shrinking the horde. Measure the crowd BEFORE callingmeleeSwipe— it kills everything in reach, so counting after counts the survivors and the cooldown barely moves. render()clears the whole frame topal.outfirst. There was NO clear for a long time: 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 BELOW where that projects the canvas was simply transparent. On a phone that read as a duller band across the bottom of the screen. The quad now runs to z=60 as well, so the perspective floor reaches the bottom on any aspect ratio. If you ever add a new orientation or stretch the canvas further, check the bottom rows are opaque — samplegetImageDataalpha, the colour alone will not tell you, because the page behind is deliberately the same green. Do NOT try to cover the bottom by extending the out-area quad instead.proj()divides by(camZ - z), so as z approachesTUNE.camZ(48) the scale explodes and past it goes NEGATIVE — a quad taken to z=60 folded its far edge back above the screen as an inverted triangle. The clear is what covers the screen; the quad only has to cover the court.- The page gradient goes on
document.documentElement, andhtmlneeds a real height. Two separate CSS traps, both hit: the ROOT element's background is what paints the viewport, so setting it onbodywhilehtmlalso has a background painted nothing; and a GRADIENT is laid out in the root's own box, which collapses to zero when the canvas isposition: fixed, sohtml { height: 100dvh }is load-bearing rather than cosmetic. - The page background behind the canvas tracks the live court apron (
pageBackgroundin render.js). It is visible in exactly two places: the strip above the canvas under the status bar, and the slivers a phone's ROUNDED DISPLAY CORNERS clip out of the canvas — a flat dark body drew a visible dark notch where the bottom corners curve in. It is a two-stop gradient:#10141cdown to the top inset, thenpal.outfor the rest.pal.outmust be read live, because it lerps between the tennis and splatter palettes and any fixed hex would only match in one mode. Written in resolved px rather thanenv()so the declaration can never be invalid and drop the background, and only assigned when the string actually changes. - The safe area is handled ASYMMETRICALLY, on purpose. TOP: index.html insets the canvas element
below the status bar, because that is opaque UI the scoreboard must clear -
SAFE.topstays 0, and adding a HUD offset there as well subtracts the notch twice. BOTTOM: the canvas deliberately runs to the PHYSICAL bottom edge so the court art fills the screen (the home indicator is a slim translucent bar, not opaque UI); only the interactive RACK and the draw meter lift clear of it, viaSAFE.bottom, whichmeasureSafereads from the hidden#safeprobe (env() is CSS-only). Reserving the bottom inset on the element instead just left a dead band. - The canvas is STRETCHED by CSS (100vw x 100dvh minus insets) and
fitViewportderives the logical grid from the box it was actually given —cv.clientWidth/clientHeight, neverinnerWidth/innerHeight. Sizing it the other way round (compute a scale, then set explicit px) laid it out against whateverinnerHeightreported at load, before iOS Safari's toolbars had settled, and it stayed a strip short of the bottom forever after.100dvhis what tracks those toolbars. - The swish cooldown scales with how swarmed you are (
meleeCooldown()in gameplay.js):meleeCool / (1 + (near-1) * meleeCrowd), floored atmeleeCoolMin, counting bodies withinmeleeCrowdR. 1 body 1.00 s, 4 → 0.47, 8 → 0.27, 14+ → 0.22. Oliver died on every game-20 horde because the wave grows withhordeGrowwhile the panic button was a flat 1 s; he chose scaling the melee over shrinking the horde. Measure the crowd BEFORE callingmeleeSwipe— it kills everything in reach, so counting after counts the survivors and the cooldown barely moves. - The canvas is deliberately FULL-BLEED under the notch and home indicator; the HUD is what respects
the safe area.
viewport-fit=coveris set, and the stands running to the screen edges look right — but an iPhone's clock was landing directly on the scoreboard. A hidden#safeprobe div carriesenv(safe-area-inset-*)(env() is only readable from CSS),measureSafe()converts it to LOGICAL canvas px using the same scalefitViewportjust applied, andSAFE.top/SAFE.bottomshift the scoreboard down and lift the rack +METER_Yup. Anything new that anchors to the very top or very bottom of the screen must offset by these too — and the rack's hit test sharesrackGeom, so taps follow automatically. UIin config.js is the accent palette (hot/warm/pick/cream/dim/bad/ball). Every HUD colour goes through it. Gore reds are deliberately NOT in it — the splatter stays loud.F(size, bold)in render.js builds every canvas font string. Baremonospaceresolves to Courier, a slab SERIF — that was the "generic serif" bug, not taste. Never hand-writectx.font. Widest string checked fits 270px portrait with ~10px spare.- Weapons are only ever WON.
deliverTin()has exactly one caller:resolveInterlude('P'). The schedule (TUNE.duelFirst/duelEvery,G.duelDue) calls the duel, fired fromproceedAfterClear()— the one beat where the court is guaranteed empty. The game beat it interrupts is parked inG.afterDueland resumed when the duel resolves. - Anything that blocks serving. If a "can't serve" report ever comes in, check these first —
each is an early return in
keyPressed:G.mgT,G.horde,G.changeover,G.interlude.introT,G.modal.G.mgTin particular must keep counting down in EVERY phase (it used to tick only duringrally, so a duel callingsetupPoint()stranded it above zero forever = permanent lock). - Animation frames step off DISTANCE COVERED, never elapsed time —
boy.distfor ball boys,zb.z / TUNE.bruteStridefor brutes. Time-driven cycles moonwalk the moment speed and frame rate disagree. Sprite heights follow the PNG (img.height / SPRITE_H) because_slice.pycommon-scales a sheet; a wound stage's height must ALWAYS come from its A frame or it pops. - Fire chains.
zb.burnT/burnD;ignite()is read-only overzombies[]so it is safe inside a kill loop;burnTick()returns true when the fire killed the body and the caller MUSTcontinue. The flame ball also ignites what it FLIES PAST (TUNE.burnBallR) — without that its full pierce killed its own lane outright and nothing was left to catch. Flames are drawn procedurally (drawFlames), no sprite sheet. - Any loop over
zombies[]whose body can kill can cascade into a sweep and gut the array mid-iteration — keep theif (!zb) continueguards and iterate backwards. - The ball boy is ONE slot.
sendBoynever replaces a boy already on court (a kill streak used to teleport him back to the wing several times a second); new work is appended viajobs.mopRoundwalks him to a stain, holds him there forTUNE.boyMopDwellwhile the push cycle animates in place, then moves him on. Three sheets:x_boymop(stopped, working),x_boycarry(walking with the mop shouldered),x_boyrun(tennis-mode fetch). - Crowd applause is a real two-frame swap (
x_crowdseated /x_crowd2arms up), per column on its own phase, with no vertical offset. A fractionaldyon a scaled image re-snaps its pixel rows every frame under nearest-neighbour — that boiling beside the HUD was the "scoreboard flicker" (the board art is 99.9% opaque; nothing was ever showing through it). If you touch one crowd frame you must apply the identical curve to the other, or the swap starts flashing brightness.assets/_sheet_crowd1.pngis the pre-brightening original.
Settled feel decisions — do not regress silently
- Two-stage serve: touch = reticle, drag = aim, release = fire, drag back under
pullMin= cancel. The pull's DIRECTION picks the lane, its LENGTH is power — deliberately decoupled so a far shambler never costs a longer drag than the screen has. - The manual wave clear after a won game (
G.clearing). Oliver was emphatic. - Thin, dynamic blood. Open wounds gush live particles (
bleed()); blood is never painted into a sprite. Gore piles/stains are the loud part; keep the trickle sparse. - One gesture means one thing on touch. No mode where a drag silently becomes something else.
- Volleys: pre-bounce contact gets reach ×1.4 / window ×1.8 (
VOLLEY). A RISING ball is hittable at any height; only a dropping ball belowCONTACT.minYis past you. - Aim magnet:
flickTargetpicks the best-ALIGNED body, withTUNE.aimTieof slack in which a nearer one still wins, so a straight-ahead lane still eats the nearest body first.aimTie: 1restores the old "nearest along the lane always wins" for A/B. TUNE.fps60 — 30 was vetoed on feel.- The player only walks during
rally. Never between points. Letting him walk during the 'over' beats andpretosswas shipped once and vetoed immediately: "I basically can never walk around freely" — so suddenly being able to, only between points, reads as the game breaking rather than as a fix. Frozen-with-danger-approaching is fixed by holding the DEAD instead (heldinupdateZombies), never by handing back movement. - The player must always be ON SCREEN.
P_BOUNDS.xis 6.5 m but portrait only shows about ±4 m either side of the centre line at the baseline, so the walk bound alone let him leave the frame entirely.xLim = Math.max(xVis, COURT.W / 2)— the camera limit, floored at the sideline so every legal ball stays catchable.
Verification recipe (the regression net)
Drive the game deterministically via window.T with javascript_tool. The Browser-pane eval
context reports document.visibilityState === 'hidden', so RAF is paused; halt it anyway with
window.requestAnimationFrame = () => 0 and step with T.step(1/60). T.press('Space') = tap.
Clear the intro first: document.getElementById('intro')?.remove().
- You MUST set
T.TUNE.autoAssist = 1.15first — the Browser pane reportspointer: fine, so the mobile assist is off and every ball reads as "never reachable". - Force splatter with
T.forceZombies()then ~600 steps (in splatter it forces a HORDE instead). T.zombies,T.spawnZombie(x, z, 'a'),T.TUNE.zMax = 0to stop spawns during tests.- Also exposed:
T.explode,T.splatPoint,T.startDuel,T.resolveInterlude,T.deliverTin,T.flickTarget,T.showModal/T.dismissModal,T.render.
Benchmarks that must keep holding (re-run after any physics/aim change):
- CPU returns ~11/12 serves (91.7%). Recent 150-sample runs: 90.7%, 89.3%, 90.7%, 88.7% (±2.5%).
Watch this — the last few sit under the bar rather than scattered around it.
The harness for this number was never written down and I could not reconstruct it. A rig that
counts "did the CPU get a racket on the served ball" scores 100/100, because after a flub the CPU
gets another swing at the SAME ball (
aiSwingCool = 0.5) and connects — 91.7% ≈ 1 − aiFlub, so the recorded number must count only the FIRST swing opportunity, or only returns that land in. Until someone writes the rig down, prove "no tennis regression" differentially instead: run your rig,git stashthe change, reload the modules, run the identical rig, compare. Equal numbers on the same rig is the real claim; a bare percentage against this line is not. - 30 human-tapped points (tap when the incoming ball is 2.2–3.2 m away) = 50 hits / 0 whiffs.
This one DOES reproduce exactly — drive it off
G.missLog(HIT vs MISS counts). - A 26-game state-machine soak with bombs/bursts/fire must finish with no exceptions, no stall,
mgTback to 0, and gore/piles arrays bounded (gore caps at 40, piles at 30). Two ways a soak lies to you, both cost me a run: force-spawning zombies duringG.clearingmakes the hand-clear un-completable and reads as a 30-minute stall; and reviving the player after eacheateninflates gore past its cap, because the death push (zombies.js, theeatenRhit) is the one uncappedgore.pushin the file — fine at one death per run, not at 260. Respectclearing, and either end the soak on death or setTUNE.eatenR = 0to make him untouchable. - Never trust a single sample. aiFlub is 9%; a 20/24 run is normal noise. Use 150.
Asset pipeline
-
generate-imageskill (needsOPENROUTER_API_KEYin the gitignored repo.env; run with the scratchpad venv python — system python lacksrequests). Passing the existing sheet as--input(edit mode) nails the style and is how every matching sheet here was made. -
Ask for a magenta background and, for sheets, "at least 150px of empty magenta between frames" plus "the prop must stay inside its own frame".
-
python3 assets/_slice.py sheet.png out1.png ...— chroma-key on the corner colour, magenta de-fringe, column-gap segmentation, common-scale per sheet,IDLE_TARGET_H=112.--largestkeeps only the main blob. -
Standalone art (logo, popup panels) uses
assets/_key.pyinstead: chroma-key + crop to the content bbox + optional downscale, no frame splitting, no forced height. -
Register new names in
render.js'sSPRpreload array. -
New sprite not showing up? Bump
SPRVin render.js. Sprite loads go throughassets/<n>.png?v=SPRVfor the same reason index.html versions main.js: python http.server sends no cache headers, so the browser caches sprite responses heuristically — including 404s. Add a name to theSPRlist before its PNG exists, load the page once, and that browser treats the sprite as permanently missing whiledrawZombiesilently falls back to another design. Bump SPRV alongsidemain.js?v=N. -
When a scripted
str.replaceedit is meant to rewire logic, ASSERT it matched. A silent no-op replace leftdrawZombieon its old hardcoded boss names while everything else had moved to the BOSS_ART table, and the built-in fallback then hid the failure perfectly — all four boss designs rendered as design 1 and nothing errored. Cost a full debugging round.drawImageinstrumentation is what finally settled it:const o = CanvasRenderingContext2D.prototype.drawImage; CanvasRenderingContext2D.prototype.drawImage = function (img, ...a) { log(img.src); return o.call(this, img, ...a); }; -
NEVER let two sprite names differ only by CASE. macOS is case-insensitive: writing
assets/z_B1.pngfor the chapter boss silently OVERWROTE the 'b' shambler'sassets/z_b1.png(andz_B2atez_b2), so a 3-metre boss started showing up in the shambler crowd andgitreported the shambler frames as "modified" rather than reporting a new file.lsshows only the ORIGINAL lowercase name, which is why it is easy to miss. Boss frames arez_boss*now. Use all-lowercase, whole-word names. -
The prompt sets the ART RESOLUTION, and it is easy to set it wrong. Sheets asked for "big chunky pixel blocks about 8 screen pixels across" and "flat blocks of colour, no shading" came back coarse — the generator obeys that literally, and Oliver reads it instantly as "waaaay lower resolution than z_mach2". Ask instead for HIGH resolution: "build each figure out of roughly 90 to 110 pixels of height instead of the reference's 40", then enumerate what to spend it on — teeth, sunken eyes, individual fingers, seams, hems, folds, frayed edges, laced shoes, four or five shades per material with hatched shading.
assets/_regen.pycarries the working style block. -
assets/_regen.py+_regen_batches.jsonredraw a whole character end to end: reference sheet from the sprites in the repo -> generate ->_fine.py-> colour-match onto what it replaces -> install, with three automatic retries. It REFUSES to install a bad sheet: wrong frame count, any figure touching a canvas edge (that is the bug that clipped z_f1), or a redraw that is not at least 1.25x finer by inner-edge density.--listshows the batches,--allruns them. -
assets/_fine.pyis the slicer for detailed art._slice.pyresamples NEAREST, which is right for chunky art and shreds fine art at a non-integer ratio._fine.pysnaps to the generator's own art-pixel grid, common-scales in ART pixels off a chosen--anchorframe (so a smash with the arm overhead stays legitimately taller), quantises back to the sheet's palette, and de-fringes the key colour twice — the generator does not always return a pure magenta and the halo survives as a rim on a green court. -
Two-row "style reference / redraw this" input does NOT work. The model returns both rows and invents a background. Describe the target detail level in words instead.
-
assets/_derive.pyis where a frame that must MATCH another frame is made. Three of them live there now:p_bounce2(a per-column shear ofp_bounce— the free arm bends, nothing else moves),p_toss(the drawn-in ball cut out, canvas height KEPT so drawChar keeps his scale) and the runner's hip registration. "Same drawing, one small difference" is the one request the generator cannot fill: it returns a different man and the swap reads as a flicker, which is the defect you were fixing. The bounce/toss steps are idempotent; the runner step is--runnerand must run exactly once per generated set, because it mirrors and it pads. -
A new pose needs a STANDING frame in the same sheet, as frame 0, thrown away after. A sprite's drawn size comes from its total height, so a sheet of nothing but arms-overhead poses has no honest scale to anchor on and the man comes out short.
_regen.py'sreffield does this, but only whenref_onlyis unused — withref_onlyset, list the throwaway as an extra sprite (p_pull0) and delete the PNG afterwards.
Known generator quirks, all still true:
- It sometimes ignores the magenta (harmless — the key uses the corner colour).
- It draws near-identical animation frames unless you describe strongly contrasted poses.
- It adds props the A frames don't have (a dragged pole on every brute B frame, a mop head). If the prop is a separate blob, keep the largest connected component; if it touches the body, cut on column density — a torso is dense, a dragged pole is a thin diagonal of ~7 px per column.
- If frames merge (a handle bridging the gap)
_slice.pyreports "found 1 frames". Re-prompt for wider gaps first; if that fails, cut at the true column gaps plus the thinnest column inside the merged pair, then common-scale. - Colour-match any regenerated frame of an existing pair before shipping: fit a per-channel linear map onto the original's mean and sd, then verify with a mean-difference number.
Session log: the serve is the gesture (2026-08-01)
Oliver's notes on the art pass: the zombies are good, the runner "flickers very hard", and the player has three problems. The serve is now a drawn wind-up driven by the pull.
- The pull IS the wind-up. Four new frames (
p_pull1..4, batchplayer_windup), chosen indrawCharoffpullFrac(G.aim.mag). What separates the four is HOW FAR THE RACKET HAS FALLEN, and that only came out once the prompt pinned the racket head to a different BODY LANDMARK per frame — above his head, beside his ear, between his shoulder blades, down at the back of his thighs. Described as "further into the wind-up each time" it drew four near-identical poses twice running (Oliver: "pull3 and pull4 are now basically the same image"), which is the generator quirk the pipeline notes already warned about, in a place I did not expect it. The stages are NOT spread evenly: frame 1 owns the whole first half of the pull and the three coiled frames are packed into the top half, Oliver's call, so the part of the gesture that costs power is the part that visibly costs effort. Release firesp_smash. Verified: 30/74/95/125 px of drag -> pull1/2/3/4. - The whole player is RIGHT-HANDED now, and that was the real bug.
p_idle,p_bounceandp_tosshad been drawn holding the racket in his LEFT hand whilep_smashandp_swingused his right, so any new frame copied whichever reference it was given and the racket changed hands mid-serve. Everything from the idle to the wind-up was regenerated with the hand named explicitly: drawn from behind, his right hand is the RIGHT-HAND SIDE OF THE IMAGE. Only the racket HEAD crosses behind his back in the deep frames — the arm never does. - The bounce pair is one drawing from one sheet, differing only in the free hand, so the swap
cannot flicker; it follows the BALL (
|sin| > 0.5) rather than its rising phase. He pats the ball with his LEFT hand now, soBOUNCE_IDLEmoved to the other side and was re-measured off the sprite (dx0.36,apex0.68). The served toss moved to+0.12— nearly over his head, because the tossing hand is on the left and the racket meets it on the right, and a big offset either way is wrong for one of those two frames. - The runner still carries a hand-mirrored fix from the previous commit (see OPEN) — its frames need regenerating, not mangling.
THE RULE, from Oliver, that this session existed to learn: "fixing generated art by mangling
with it afterwards is always worse than regenerating. it's just a fact." Mirroring a frame,
shearing a limb, cutting a prop out — a wrong frame is wrong in more ways than the one you spotted
(perspective, foreshortening, where the weight sits) and none of those survive a flip. There was an
assets/_derive.py doing exactly that; it is deleted. Fix the PROMPT and re-run the batch.
Three pipeline guards were added to _regen.py, each after a bad sheet installed silently:
- The reference's ASPECT is the answer's aspect. One tall reference figure gave a portrait
reference canvas and the generator answered with a 2x2 GRID, which
_fine.pycannot cut (it segments columns).ref_sheetnow widens to a landscape strip with one slot per frame asked for. - A non-magenta background is a rejected roll. "It sometimes ignores the magenta" was written off as harmless because the key uses the corner colour — until it returned a GREEN sheet and keyed away four grey-green racket heads with it, installing four men holding a grip and nothing else.
arm_leftrejects a MIRRORED frame. The generator flips the later frames of a serve about half the time, which puts the racket in the other hand halfway through the animation. Oliver caught it twice by eye before the check existed. It is a net, not a proof: it asks whether anything reaches the top of the LEFT half, and a mirrored frame whose racket head hangs over there passes. A tighter discriminator was tried (ink mass in the top third, left vs right) and does NOT separate the two — measured 0.61/0.57/0.41 on a mirrored sheet against 0.54/0.56/0.55 on a good one. Every wind-up sheet still has to be looked at.
Still imperfect, and worth a look on the phone: the empty tossing arm WANDERS between the four wind-up frames (up, out, bent, up) instead of staying thrown up at the ball. The roll that held the arm still put the racket back in the wrong hand, and the racket is the load-bearing half of the gesture, so this is the set that shipped. One more roll may fix both.
Also worth knowing: a batch of raised-arm poses has no honest scale to anchor on, so frame 0 is
always a throwaway STANDING frame (p_pull0, deleted after installing). And a photo reference of a
real serve is what finally got the poses right — Oliver supplied a 10-frame sequence, and the four
stages are its frames 3, 4, 5 and 6, the last being maximum tension before release.
Session log: the art-resolution pass (2026-07-31)
What changed. The whole character roster was redrawn at real pixel-art resolution, plus a batch
of gameplay fixes. Everything is on main and deployed; the start screen shows a BUILD timestamp
so a phone can be checked against git log -1 --format=%cd.
Gameplay this session: the horde machine-gun buffed until a grown wave is survivable (rate scales
with hordeGrow, fat pellets, 2 hp each) and hordeRate raised to leave a standing crowd behind;
the steward ('d') added as an ordinary body between shambler and brute; a pending horde can no
longer fire into a boss fight; bodies stopped blinking on the way in (negative-z modulo); the ball
machine rolls, dents, and dies as machinery with smoke; the player's death is five drawn frames
with the crowd converging and dispersing; wounded bodies walk; side-on zombies mirror when they
walk right; the racket zombie's cycle steps off ground covered.
The lesson that cost the most. The prompt sets the art resolution, and I set it wrong for eleven sheets by asking for "big chunky pixel blocks, flat colour, no shading". See pipeline note 9.
The lesson that cost the second most. THE REFERENCE SHEET IS WHAT THE GENERATOR COPIES. Building
it out of the sprites you are trying to change reproduces exactly what you wanted gone — a
front-facing player stayed front-facing and a side-on runner stayed side-on through three retries
each. _regen_batches.json supports ref_only for this: the frames it LOOKS at, separate from the
frames it replaces. Draw the back view from the one true back-view frame; draw a front-facing
sprint from a front-facing zombie.
Anything that must stay pixel-identical between two frames: do it in code, not in a prompt. The brute's wound moved between his two feet across three separate regeneration attempts. B frames are mirrors of their A frame now.
Known limitations, in priority order for the next session:
z_gwb(brute, one arm gone, dragging the pole) is a LEG-BAND mirror ofz_gw, because a whole frame mirror swaps the pole into the other hand. A leg mirror keeps both feet in the same two positions, so it reads as a leg swap rather than a stride. The fix that worked for the shamblers: generate the second frame as a PASSING pose (feet together under the body, one knee lifted) with the prop explicitly in the same hand — a walk needs the foot GAP to open and close, which is the measurement to check (z_aw75px ->z_awb65px is a real stride; equal gaps are not).- Brute damage stage 3 was drawn slimmer than the others once; watch that the four stages keep one
body size (they are drawn from
z_g1in two sheets now). - "Player looks transparent while running" was reported and then reported gone. Never reproduced: the run frames have zero semi-transparent pixels and every globalAlpha in render.js is save/restore'd. If it returns, establish first whether the court shows THROUGH him (sprite) or he flickers (renderer).
- The chapter bosses, the judge, the ball machine and the weapon icons were deliberately NOT redrawn — Oliver named them as already right.
A sprite's drawn size comes from its TOTAL height, so anything held above the head eats into the body. The serve frames were 29% racket by height and the man came out a fifth too small; they are scaled so the BODY matches the standing frame (98% of sprite height) and the racket overhangs. Check this on any new pose with a raised prop.
Gotchas learned the hard way
- Stale ES modules are the #1 time sink. python http.server sends no cache headers, so an edited
module often loads STALE.
preview_stop/preview_startonly helps if the port actually changes, and holding 8641 with a throwaway socket did not work reliably. What DOES work:then reload the page. Always sanity-check a just-added field before trusting any result — and note the trap: ifawait Promise.all(['main.js','state.js','gameplay.js','render.js','config.js','zombies.js', 'input.js','audio.js','panel.js','dom.js'].map(f => fetch(f, {cache:'reload'})));state.jsis stale butgameplay.jsis fresh, a newGfield materialises as NaN (undefined + dt), which reads exactly like a logic bug. drawCharmirrors sprites bych.facing— any stance that must not mirror needs facing reset.- Synthetic touch tests: input.js only sets
stick.movedin touchmove, so touchstart→touchend reads as a TAP. Always dispatch a touchmove between them. - Contact/kill tests must step 1/60 — pellets at 26 m/s tunnel past 0.55 m radii at coarser steps and kills silently don't happen, which looks exactly like a bug.
- When testing fire or any serve, fire a real ARC. A flat ball from z=16 hits the net (netH 0.914) and bounces back at you; a "the weapon is broken" reading was my test hitting the tape.
- The scratchpad venv gets wiped between sessions — recreate it with
requests+pillow. - Commit style: imperative summary +
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>. Push = deploy.
How to work on this — the meta-lesson from four playtest rounds
Every single "this feels odd" note Oliver gave this session turned out to be a real defect, not taste. Not one was a tuning preference. The score so far:
| what he said | what it actually was |
|---|---|
| "the racket zombie doesn't seem harmable, but that could just be a feeling" | 3 of 5 weapons silently no-oped on it |
| "I could not return a single ball" | the parry was unreachable — the tap only became a swing in rally |
| "the first boss is way too easy" | a flame ball re-wounded it every frame; one killed a 16 hp boss |
| "the ball is not yet at the player but he is already hit" | contact fired 0.68 m early (weighted depth) |
| "one of the endboss sprites is used for a normal zombie" | a case-insensitive filename collision |
| "everything is a tad too small" | near sprites were being upscaled 1.5x from a 112px source |
| "I die all the time at the game-20 horde" | the horde scales, the panic button was a flat 1 s |
So: when he reports a feel problem, go measure it before touching a tuning value. The measurement is usually cheap (project both objects to screen space at the moment of the event; count device pixels against source pixels; print the schedule for 45 games) and it has found a bug every time.
Verify with instrumentation, not with screenshots. Small pixel art at phone scale is genuinely unreadable in a screenshot — during the boss-design work I twice concluded from screenshots that the sprites were fine when they were all silently falling back to design 1. What settled it in one call:
const o = CanvasRenderingContext2D.prototype.drawImage;
CanvasRenderingContext2D.prototype.drawImage = function (img, ...a) { log(img.src); return o.call(this, img, ...a); };
Same idea elsewhere: project to screen coords and compare numbers, don't eyeball. A silent fallback
(SPR[wanted] ? wanted : <default>) is convenient in production and poisonous while debugging,
because a wiring bug renders as perfectly working code.
Assert your scripted edits matched. A str.replace that finds nothing is a silent no-op. One of
those left drawZombie on hardcoded sprite names while everything else moved to the BOSS_ART table,
and the fallback above hid it completely. Every scripted edit in this repo should assert a in s.
OPEN — leftovers, in rough priority order
Nothing here is broken; these are known gaps and unverified risks.
RES4 is unverified on a real phone under load. It nearly doubled the fill rate (810 -> 1080 device px across). Oliver has not reported stutter, but he also has not played a late horde since. If the game ever stutters,RESback to 3 is the first move — not reducing zombie counts.- The game-20 horde fix is unplayed. The crowd-scaled swish (1.00 s at one body -> 0.22 s at
fourteen) is measured but not yet felt. If it now trivialises hordes,
meleeCoolMinis the knob. p_idle1/p_idle2and mosto_*frames are 2x-upscaled, not re-sliced. They are on the right scale but gained no detail in the remaster, and they are the two most-seen poses in the game. Fix by hand-slicing_sheet_player2/3and_sheet_opp2/3atIDLE_TARGET_H224 rather than going through the signature matcher, which assigned those frames elsewhere.- Boss designs are asymmetric. Design 1 (GROUNDSKEEPER) has 3 wound stages and 4 throw frames;
designs 2 (LINE JUDGE) and 3 (CHAIR UMPIRE) have 1 wound skin and 2 throw frames each. Legal — the
BOSS_ARTindices are clamped — but a chapter-2 boss degrades in one visible step instead of three. - Only 3 boss designs, cycling. Chapter 4 repeats the groundskeeper at 43 hp and 3.67 m. A fourth
design is a sheet plus one row in
BOSS_ART. - Landscape is under-maintained. The readability pass (camZ/camY/camF/horizon), the adaptive height and the safe-area work are all portrait-only; landscape still runs the old camera and a fixed 480x270. Portrait is the stated primary target, but landscape has drifted and is untested.
drawCard(the chapter/set cards) still cuts in with no entrance, while modals swoosh (TUNE.modalIn). Offered and not yet requested.- A chapter can be skipped.
G.bossDuetakes the max, so going a long stretch without ever clearing the court means you face the current chapter, not a backlog. Deliberate; noted in case it ever reads as a bug. - The runner's four frames were fixed BY HAND and must be regenerated.
z_f1..4are currently hip-registered (padded to one width with the hip on the centre line) and frames 3 and 4 are mirrored copies, because all four were drawn leaning the same way and lifting the same leg. That is exactly the post-processing Oliver ruled out — it shipped before he stated the rule. The batch to write asks for a four-frame front-on sprint whose stride and lean ALTERNATE, and the numbers to check afterwards are the per-frame hip centroid (it was 13 px of lateral jump at 12 frames a second) and which leg leads. - Step 6 stays PARKED by Oliver's decision: the crowd turning into zombies (spawning from the
stands — attacks from behind, high spectacle,
x_crowdsheets already exist) and the ball-boy zombie that catches your shots. Recorded so the ideas are not lost, not as a to-do. ump_no_dealdoes not exist. Oliver asked for its removal;ump_no_appealwas removed as the closest match. Worth one confirmation that no other line is still unwanted.- Four sounds are missing and need RESEARCH first (find/choose the source before implementing —
every existing sfx is either synthesised in
audio.jsor a wav inassets/audio/):- the ball machine while it moves — a squeaking wheel/castor, so the furniture announces
itself. It already telegraphs its shot (
machWind0.9 s); this is the approach cue. - burning zombies — fire is an epidemic now (
burnSpread,burnTime3 s), so this is a looping crackle plus something the body does, and it has to layer without turning a lit-up wave into noise. - the melee gun — i.e. the machine-gun burst (
mgRate12/s during a horde,mgPickupLen8 s from a tin). At 12 rounds/s a per-pellet sample will machine-gun the mixer, not just the wave. - boss sounds — footfall, wind-up, throw, wound, death. Three designs in
BOSS_ART; decide whether they share one set or get one each before recording anything.
- the ball machine while it moves — a squeaking wheel/castor, so the furniture announces
itself. It already telegraphs its shot (
LATER — shipping it: demo, stores, campaign
Decided in discussion 2026-07-31 with Oliver. None of this is built. Recorded here so the decisions survive; the ordering below is roughly the order to do it in.
The model: paid, $2.99, no ads. Both stores. No ad SDK, no consent/CMP flow, no ATT prompt, no
tracking — which keeps the store card at "Data Not Collected", keeps review trivial, and keeps a
zero-dependency project zero-dependency. Maybe later: a free build capped at chapter 1 with an IAP
to unlock all chapters. That is a fallback, not the plan — paid→free is a normal move, free→paid is
not, so launch paid.
Ads are rejected, with one exception held in reserve: rewarded video on the CHALLENGE (below),
never an interstitial. An interstitial after death would land exactly in the "one more run" gap that
EATEN_RESTART exists to protect, and would end sessions rather than annoy.
- The capped web demo. The public web build stops at the end of chapter 1 — after the first
chapter boss is beaten (
G.runGames >= 10,bossEvery), on the chapter-2 card, ending on a WIN, not a wall: "CHAPTER 2 — continue on the App Store." The cut must sit after everything a promo clip promises (transition, first horde, a duel, weapons, the boss), because the web build's whole job is converting a 15-second clip into "I played it". Implementation is ~10 lines: a check on the chapter-2 card plus a build flag the store build sets to skip it. Build no protection around it — it is client-side JS on a static host, trivially bypassable, and nobody who would pay $2.99 will open devtools. DRM here is pure cost. Consider leaving desktop uncapped (matchMedia('(pointer: coarse)'), already used forautoAssist): nothing to cannibalise there, and an uncapped desktop build is what gets linked. - Store readiness. Wrap the PWA (Capacitor/PWABuilder). Unknowns to check before committing:
audio behaviour in WKWebView, safe-area (already solved for the browser, may differ in the shell),
and 60 fps canvas under WKWebView at
RES4 — see OPEN 1, this is the second place that risk lands. Costs: a build step in a deliberately build-step-free project, Apple $99/yr, Google $25 once. Landscape (OPEN 6) probably has to stop being under-maintained before a store review. - The CHALLENGE — ship it FREE. Tennis already owns the paid-continue mechanic: Hawk-Eye. On
death (
G.mode='eaten'), the umpire announces a challenge, the replay shows the shambler's foot over the line, and the call is overturned — you resume. The sport supplies the cap for free (challenges per set), so the limit is diegetic rather than a paywall excuse. Build it as an earned mechanic, not a purchase. It is good design on its own, and it is the only slot in the game that could ever carry a rewarded video if the model changes. Note it does change what a run means — right now the run summary is the whole game — so it needs a playtest on feel before it is assumed good. - Short-form video campaign. The only free channel that reaches people who have never heard of
the game; the algorithm serves cold, so every post is a free lottery ticket. Format: vertical
(portrait is already primary — no letterboxing, unlike most game footage), open mid-rally on what
looks like an ordinary SNES tennis game, reveal the transition at ~3-5 s. Sound matters: the
recorded umpire over splatter is what earns an unmute. Volume over polish — 20-30 clips, one
export cross-posted to TikTok/Reels/Shorts. No devlog content (it reaches developers, who do
not buy games).
Clips the game already contains: the transition (the flagship), a boss walking on, the ball boy
calmly mopping blood, a flame ball starting an epidemic across a lined-up wave, parrying a boss's
volley back into him, a horde, the death + run summary.
A capture helper on
window.Tto jump straight into a given beat (the transition timeline is onG.transT) would make clean takes cheap — not yet built.
Expectations, so nobody is surprised: with no marketing, 20-100 units in year one is the honest central estimate (~$40-210 net, i.e. under Apple's fee at the low end). The monetization model is worth maybe $200 either way; the video channel is worth ~100x that. Ship paid because it is cheap and protects the design, not because it will earn.
Explicitly NOT to be built (these fight settled decisions, see the invariants above):
- selling weapons or tins — weapons are only ever won, in the duel; a shop deletes the duel's
reason to exist and
healPerDuelwith it. - a meta-progression currency — permadeath and the run summary are the meta layer.
CLOSED — Oliver's fourth playtest (2026-07-30)
The game did not use the full height of his iPhone
Fixed by making the portrait logical height adaptive and letting horizon ride with it — see the
invariant above. On a 375x812 pane: logical 270x585 (was 270x480), canvas CSS 375x812, 100% of both
axes. RES 3 -> 4 for the "extra pixels" he asked about.
Dying on every game-20 horde
The swish cooldown now scales with crowding — see the invariant. Note the ordering trap recorded there; the first implementation measured the crowd after the swipe had already killed it, so the cooldown only moved once ~14 bodies were present.
The weapon message was lost when the heal popup fired
showModal deliberately never clobbers an open modal, so winning a duel while wounded opened the
heal modal and the weapon modal was silently dropped. deliverTin(healLine) now folds the trainer
into the WEAPON popup as an extra line, so there is one popup carrying both. An unwounded duel win
still shows the plain weapon popup.
The mop boy walked out with nothing to mop
sendBoy now returns early when mop is set and no stain passes the same test mopRound uses to
pick a target (a >= 0.15 && z >= COURT.netZ - 2) — he was walking on, finding nothing reachable,
and walking straight off. This needs state.js to read gore, which adds a third runtime-only
import cycle (state <-> zombies) alongside input<->gameplay and zombies<->gameplay; it is safe for
the same reason, gore is only touched inside sendBoy at runtime, never during module evaluation.
Regression net
30 tapped tennis points = 50 hits / 0 whiffs. 45-game soak: no exceptions, all four chapters,
mgT drains, gore 40 / piles 30 / bodies capped at 34.
CLOSED — Oliver's third playtest: audio, hit timing, readability (2026-07-30)
The umpire talked too much and at random
Rebuilt as situational triggers behind one shared cooldown — see the invariant above. asideEvery
26 s -> 55 s. Removed ump_out_of_order, ump_let, and ump_no_appeal (he asked for
"ump_no_deal"; that line does not exist and ump_no_appeal is the one he meant — confirm if a
different line is still unwanted). 11 ump_* lines remain.
"Visually the ball is not yet at the player but somehow the player already is hit"
Real, and measurable. The hostile-ball hit test reused the PARRY's hypot(dx, dz * 0.8) — weighting
depth by 0.8 SHRINKS it, so contact fired while the ball was still most of a metre up-court. Measured
at the moment of the hit: dz = -0.68 m, and the ball drawn at screen y 314 against a head at 305 —
up around his head, not on his body. Fixed with a separate unweighted test (TUNE.shotHitR 0.42,
shotHitY 1.75). Re-measured: contact at dz ≈ -0.25 m, ball at screen y 325 against head 305 /
feet 362 — squarely on the chest. The parry keeps its 0.8 weighting on purpose: a racket sweep
really is wider than it is deep.
"Everything is a tad too small" (his wife, and the best note of the project)
Measured before changing anything, because "regenerate the sprites at higher resolution" is only half
right. With RES 3, drawn DEVICE pixels vs the 112px source were:
player at baseline 170 px UPSCALED 1.52x <- higher-res art helps
shambler your half 120 px upscaled 1.08x <- helps
shambler mid-court 98 px downscaled <- cannot help; needs to be BIGGER
shambler far 78 px downscaled 0.70x <- cannot help
So near bodies needed resolution and far bodies needed the camera. Both were done.
Camera (portrait only; landscape untouched): camZ 42->48, camF 580->760, camY 9->11,
horizon 90->45. Pushing the camera back while lengthening the lens FLATTENS the perspective, so
distant bodies grow relative to near ones instead of everything merely zooming; camY rises with it
for a steeper look-down so bodies overlap less at depth, and horizon re-centres the court after
both. Result: far shambler 26 -> 30 logical px, mid 33 -> 36, chapter-1 boss 39 -> 45, player
unchanged at 57, court band 200..390 (was 199..380). If you retune these four, check all four
outputs: far size, near size, court top, court bottom.
Sprite remaster, at zero generation cost. The original assets/_sheet_*.png files are retained at
full generated resolution (~1400px wide), so nothing needed regenerating — IDLE_TARGET_H went to 224
and every sheet was RE-SLICED. The sheet-to-output mapping was never written down, so it was recovered
automatically: slice each sheet to a temp dir, then greedily assign frames to existing assets 1:1 by
LANCZOS-normalised RGBA signature distance. 58 sprites came back from sheets; the 29 with no confident
sheet match were 2x-upscaled instead, which adds no detail but keeps them on the shared scale (see the
SPRITE_H invariant — a mismatched sprite renders at half height). x_crowd/x_crowd2 were reverted
to originals: they never touch SPRITE_H and doubling them is pure memory.
Known gap: p_idle1/p_idle2 and most o_* frames were among the 2x-upscaled set, so the two
most-seen poses gained scale but not detail. Re-slicing _sheet_player2/3 and _sheet_opp2/3 by hand
(rather than via the matcher) would fix that.
Regression net
30 tapped tennis points = 50 hits / 0 whiffs. 45-game soak: no exceptions, all four chapters fired,
mgT drains, gore 40 / piles 30 / bodies capped at 34.
CLOSED — Oliver's second boss/ranged playtest (2026-07-30)
The parry was too generous
"I can tap pretty early and it returns the ball." At parryArm 6.0 / window 0.34 / reach 2.4 the
shot flew INTO reach during the window, so any tap once it was vaguely close worked. Now 3.4 / 0.2 /
1.7 — verified: a tap at 6.0 m or 4.5 m is rejected and costs blood, 3.2 m and closer returns.
The boss's three balls were decoration
"he throws 3 of them from which only ever one can reach me. that does not make sense." Correct — they
were fanned +-1.9 m across space, so only the middle one was ever aimed at the player. They are a
timed salvo now: three shots TUNE.bossSalvoGap apart, each aimed at where you are AT THAT MOMENT
with only a hand's width of scatter. Verified firing at 0.02 / 0.45 / 0.88 s, all within 0.3 m of the
player. Note hurtGrace (1.1 s) still caps a whole volley at one point of damage by design.
Reaction time also tightened: bosses get bossWind 0.5 s of telegraph against the machine's 0.9, and
throw at bossShotSpeed 15.5 m/s against its 13.
Every chapter was the same boss
Right — chapters only differed in hp and scale. There are three designs now, cycling, each with its own sheet, a 2-frame stride, a throw pose, a wrecked skin and a wrecked-throw skin: THE GROUNDSKEEPER (ch 1/4/7…, blazer + ball hopper, and the only one with three wound stages), THE LINE JUDGE (ch 2/5/…, fat, striped officials' polo, cap, ball apron), THE CHAIR UMPIRE (ch 3/6/…, tall and gaunt, panama hat, the umpire's chair strapped to his back). The chapter card names the design. Chapter 4 repeats design 1 but at 43 hp and 3.67 m. The chair-umpire sheet came back on a WHITE background, and it wears a cream blazer and a white hat — a colour key would have eaten both. Fixed by flood-filling the background inward from the image BORDER instead of keying by colour; that script is in this session's git history and is the right tool whenever a generated sheet's background colour also appears on the character.
Healing needed to announce itself
Winning a duel while wounded now opens a real modal — x_cross first-aid tin, "WOUNDS TREATED / THE
TRAINER PATCHED YOU UP / N OF M BACK" — plus an umpire line. It was a 1.4 s flash before.
The umpire needed to comment on the new reality
14 new recorded lines (say -v Daniel -r 168 -> afconvert), all ump_*. Ten are a rotating pool of
sarcastic asides fired on a jittered TUNE.asideEvery timer whenever nothing else owns the audio
(umpireAside in gameplay.js); four are hooked to specific beats — a machine arriving, a boss
arriving, the trainer, and taking a hit. The joke is that he has still never acknowledged that the
match became a massacre, so he keeps officiating it: "Code violation. Unsportsmanlike conduct.
Eating."
Regression net
30 tapped tennis points = 50 hits / 0 whiffs. 45-game soak: no exceptions, mgT drains, gore 40 /
piles 30 / bodies capped at 34.
CLOSED — Oliver's first boss/ranged playtest (2026-07-30)
Six reports, all fixed. Two were serious.
A boss sprite appeared in the shambler crowd — CASE COLLISION
z_B1.png / z_B2.png overwrote the 'b' shambler's z_b1.png / z_b2.png on a case-insensitive
filesystem. Restored from HEAD~1; boss frames renamed z_boss1, z_boss2, z_bossw1..3. The rule
is now in the asset pipeline section — this is a trap that leaves no obvious trace.
The parry was unreachable — the whole mechanic was dead
"I could not return a single ball of the ballmachine.. I tried it with taps near the hit - it never
worked", and the same for the boss's volleys. Cause: P.swingT is only set in keyPressed's
phase === 'rally' branch, and splatter is in pretoss almost all of the time, so the tap tossed
the serve ball instead of swinging. An incoming shot now owns the tap in every phase — see the
invariant above. Verified returning from pretoss, rally and over, at 0.9 m through 5.4 m, with
the three fail cases (too early / too late / no tap) intact.
One flame ball killed a 16 hp boss
Not tuning — a real bug. The flame ball is not consumed on contact (full pierce) and a boss is not
removed when hit, so it re-wounded every frame. zb.hitT + spending the ball on a boss. Chapter 1
now costs 4 flame balls or 16 clean smashes, and bossHp went 10 -> 16 (bossHpPerChapter 8 -> 9),
so chapter 4 is 43 hp.
The boss had no wounded state while throwing
Correct — the wound skins were standing poses only. New sheet _sheet_bossthrow.png gives all four
damage states their own overarm pitch (z_bosst0..3), used whenever windT > 0.
The ball machine was "naively painted"
It was — four rects. Replaced with a generated sheet (z_mach1 idle, z_mach2 firing with a muzzle
flash): a dented hopper cart on wheels with a barrel aimed down-court. The muzzle GLOW stays
procedural on top, because the telegraph has to read at 20 px tall.
The player's blood read as a painted layer
Removed entirely. See the invariant above.
CLOSED — chapter bosses + ranged attacks, steps 4-5 (2026-07-30)
Step 4 — the ranged channel
The ball machine ('m'), Oliver's own idea and the most on-brand thing on the list, because a ball
machine is real tennis furniture. It never walks, so it never crosses your baseline and never scores:
it is a standing threat you choose when to deal with, which is the target-priority decision the game
was missing. Measured: 3.4 s cycle, 0.9 s telegraph (TUNE.machWind, muzzle glow + a mechanical
click — the same fairness contract the runner's shriek() is), 3 hp, exempt from the clear gate.
The projectile is a real ARC, solved like a serve rather than fired flat, so it reads coming in and
so the swing window has something to meet. fireAtPlayer aims where you stand, which means moving
is a partial answer during a rally — and is exactly why the swish path exists for pretoss, where you
cannot move or swing.
Player damage. 4 hp, hurtGrace 1.1 s, +2 on a won duel (the only healing in the game, and now
THE reason to want a duel rather than tolerate it). Verified: 4 hits kill you and land in the same
eaten path as being caught, with eatenAt, the death animation and the best-run write all intact;
three hits in one frame cost only one point.
Step 5 — chapter bosses, and the racket zombie demoted
Demoted, as Oliver asked. The racket zombie used to respawn on every game you won, which is
precisely why he read as "the boss" and why a real boss could not have stood out next to him. He is an
occasional opponent now (TUNE.racketEvery = 3 games).
The chapter boss ('B') arrives every TUNE.bossEvery = 10 games, fired from proceedAfterClear
— the one beat where the court is guaranteed empty — and it OUTRANKS the set/horde and duel beats
there, because a boss walking on into a standing wave would be unreadable. It clears the stage,
blocks all other spawns while it lives, and threatens both channels: volleys of bossVolley = 3
chip your hp, and letting it reach you is the ordinary instant death. Chapter 1 is 10 hp and 2.7 m;
chapter 4 is 34 hp and 3.67 m.
The economy fix this was really for: tins arrive every 3 games forever, so nothing was ever worth
banking. Measured cost of a chapter-4 boss — 12 bombs, or 100 machine-gun rounds, or 34 side balls.
TUNE.wCap is 9 per type, so no single weapon can kill a late boss and a mixed, banked arsenal is
mandatory. That is the strategic pressure Oliver was asking for.
G.bossDue never moves backwards (Math.max): going a long stretch without ever clearing the court
skips a chapter rather than queueing a backlog. Deliberate — you face the boss the run has earned.
Bugs found and fixed while building this (all were silent)
- A blast deleted a 34 hp chapter boss outright:
explodeonly wounded'r'. This is whatisBoss()now exists to prevent, and it is the second time the same class of bug has shipped. - The machine's
machHpdid nothing —'m'fell through every variant branch to the generic one-shot fallback. It soaks like the brute now (3 balls, verified). - The boss both scored a kill and fired its chapter card, so two code paths set "what happens next" in the same frame.
- The machine gated the hand-clear, turning a priority decision into a chore.
- A boss death produced a shambler-sized gore burst (
GORE_MULhad no'B'; now 6 → ~305 particles).
Regression net after steps 4-5
30 tapped tennis points = 50 hits / 0 whiffs (unchanged — tennis is untouched by any of this).
45-game splatter soak: no exceptions, all four chapters fired [1,2,3,4], machines appeared, ranged
hits landed, mgT drains, gore 40 / piles 30 / bodies capped at 34.
CLOSED — the 50-game plateau, steps 1-3 (2026-07-30)
Racket zombie's HP bar removed
See invariants. Replaced by a one-per-run "IT HAS A RACKET / IT RETURNS YOUR WEAK SHOTS / ONLY A FULL
PULL WOUNDS IT" modal from spawnRacketZombie, gated on G.seen.racket.
"Sometimes the racket zombie doesn't seem harmable" — it was real, not a feeling
Three of the five rack slots did nothing at all to him, silently:
TRIPLE side balls skipped him (variant === 'r' → continue), GUN/horde pellets skipped him, and
fire spread could not ignite him. Meanwhile BOMB deleted him outright ignoring hp. All fixed via the
new TUNE.dmg* table and wound(). Verified numerically: 9 pellets kill a 3 hp boss, one side ball
takes 1 hp and is consumed, a weak side ball does nothing (correct — the zSplatQ gate), a bomb takes
3 off, and he now catches fire from a burning neighbour and cooks to death in 3.6 s at
bossBurnMul 0.4.
hitR(zb)gives him a 0.7 m contact radius (shambler 0.55, brute 0.75) — part of "unharmable" was simply missing him.- Melee is still skipped, and that is correct, not an oversight. The player is bounded at
P_BOUNDS.zMin= 13.1 and the boss holdsz <= COURT.svcFar= 5.5, so they are always on opposite sides of the net andmeleeRange1.7 can never span it. Don't "fix" an unreachable path. - A sub-threshold ball still passes through him on purpose so
racketAIcan rally it back. That rally IS the feedback that the pull wasn't hard enough, so there is deliberately no clank there.
The director un-capped
Old vs new, verified in-browser (gap s / body cap / speed m/s / advanced share / boss hp):
gap cap speed nasty bossHp
g 1 2.59/2.78 3/4 0.79/1.75 0.03/— 3/3
g10 1.15/1.00 12/13 1.61/1.75 0.30/0.27 4/3
g12 1.03/1.00 13/14 1.79/1.75 0.36/0.42 4/3
g20 0.71/1.00 21/14 2.52/1.75 0.60/0.42 5/3
g30 0.52/1.00 24/14 2.73/1.75 0.75/0.42 6/3
g50 0.35/1.00 24/14 2.73/1.75 0.75/0.42 9/3
- The spawn gap is hyperbolic now (
zSpawnEvery / (1 + games * zSpawnAccel)) so it keeps tightening instead of hitting a floor;zSpawnMinis only a safety stop. - Known, deliberate early-game change: shamble speed used to be TIME-based (
splatT / 70) and maxed out after 105 seconds of splatter — i.e. during game 2 — so it was never a progression lever and every early shambler already walked at top speed. It is games-based now, which makes games 1-10 genuinely gentler than before. Oliver has not playtested that yet; if the on-ramp feels flabby the knob iszSpeedPerGame. - Hordes scale with sets banked (
hordeGrow) — length AND spawn rate.TUNE.zHordeCap= 34 is required: a horde deliberately ignores the director cap, and with growth a late wave asks for ~120 spawns where the old fixed 12 s x 2.5/s could only ask for 30. A 41-body court showed up in the soak before the cap went in.
Regression net after all of it
30 tapped tennis points = 50 hits / 0 whiffs (unchanged). 40-game splatter soak: no exceptions,
mgT drains to 0, gore 40 / piles 30 / bodies capped at exactly 34.
CLOSED — Oliver's 35-game retest (2026-07-27), all fixed 2026-07-30
Kept because each one is a trap worth not re-digging. Every fix was verified in the browser via
window.T; the numbers quoted are from those runs.
1 + 6. Player frozen (and stuck mid-animation) during the between-game pauses — was HIGH
Two fixes: the first was wrong. The movement block — pose update included — lived inside
if (G.phase === 'rally'), so every 'over' beat and every pretoss left the player standing while
zombies walked at him. The pretoss case is issue 6: endHorde calls setupPoint().
The first attempt widened the block to all of splatter except 'toss'. Oliver vetoed it on sight:
"the mechanic where I suddenly can move freely around feels broken.. I basically can never walk
around freely - also I could walk off the screen". He is right — free movement for five seconds
between points, in a game where you otherwise only move while a ball is live, reads as a bug. It is
reverted, and the rule is now in Settled feel decisions above.
What actually ships, three separate pieces:
- The dead wait too.
held = !!G.changeoverinupdateZombiesstops every zombie'szadvance, its x steering, and the eat check for the breather's duration. They still bleed, still burn, still animate — they just stand there, because the umpire called time and it is still a tennis match. This is Oliver's own second option and the characterful one (the boys mop, everyone waits). - The stuck pose.
expirePoseonly ever expiredswing/serve;'run'was the pose nothing reset, andP.runTstops advancing outside the movement block, so a beat that began mid-stride welded him to one run frame. Anelse if (P.pose === 'run')on the movement block fixes it for every phase at once. - The on-screen clamp (see Settled feel decisions) —
xVisalready existed but was applied only in auto-fire modes, so ordinary play could walk clean off the edge. Now it always applies.
The path where this actually bit — worth knowing, because a clear court makes it look impossible:
a changeover normally starts only after G.clearing has emptied the court, so nothing is walking.
But resolveInterlude('O') spawns five fresh zombies ("the dead lose their patience") and then
hands back to startChangeover via G.afterDuel. Lose a duel scheduled on a changeover beat and you
get five shamblers plus five seconds with no agency. Verified fixed: they advance 0.000 m across the
breather and resume 0.75 m/s the instant "Time." is called.
If the 5 s still drags on a phone, TUNE.changeoverT is the knob — it is now a safe, watchable beat
rather than a dangerous one, so shortening it is purely a pacing call.
2. The mop boy walks on during the 1:1 duel — was LOW
sendBoy now returns early on G.interlude, and startDuel sets G.boy = null. Note the ordering
that makes this safe: resolveInterlude('P') clears G.interlude before calling deliverTin(),
so the boy still comes out with the tin.
3. Triple serve: the two wing balls lob off at the wrong height — was MEDIUM
The diagnosis in the old text was right. g: ball.g is now copied onto each clone (both push sites:
hitServe's triple fan and dragServe's re-fire clone) and zombies.js integrates
e.vy -= (e.g || TUNE.gravity) * dt. Verified at full pull: clones carry g = 21.16 (2.3 × the 9.2
base) instead of 9.2, and centre and wing apex heights now match exactly (2.90 m both).
4. The umpire calls "Time" on pauses that aren't changeovers — was LOW
G.changeover is now the seconds left, not a flag (still truthy, so every !G.changeover guard
reads the same), and it counts itself down near the top of update(), calling voice('time') when it
hits zero. It no longer rides the generic G.overT expiry — anything that overwrote overT mid-
breather (a melee kill taking a game, say) used to make the umpire call time on a pause that was
never a changeover. Verified with a fetch spy on assets/audio/time.m4a: an unrelated overT
expiry at t=0.4 s says nothing and leaves 4.6 s on the breather; the line fires exactly once, when
the breather's own clock runs out.
5. The transition tutorial illustration confuses people — was MEDIUM
assets/x_howto.png regenerated (Gemini 3 Pro, edit mode with the old sheet as --input, then
assets/_key.py … 240). The curved arrow and thumbprint are gone; it is now a press ring at the top,
a straight vertical dashed track, two chevrons pointing DOWN, and a fingertip at the bottom — with the
yellow shot arrow flying the opposite way to the zombie, which is the inversion lesson. Aspect is
unchanged (240×321 vs 240×322), so the modal still draws it at 90×120 in portrait. Checked at that
drawn size, not at full resolution.
7. Aiming plays the toss animation; it should stay in the pretoss stance — was MEDIUM
update()'s forced sv.pose = 'serve' for phase === 'toss' is now gated on !G.aim: while the
slingshot is drawn the server holds the pretoss stance, and only hitServe puts him into the
toss/smash. Verified: plain tap → pose serve; drawn bow → pose idle.
8. Show the current game number persistently — was LOW
GAME N now sits permanently under the scoreboard, right-aligned to the board's width, on the same
row as the NEXT pips and in the same dim tone (render.js, just before the pips block). The 1.1 s
flash when a game arms is still there.