Files
splatter-tennis/HANDOFF.md
T
wuwiandClaude Opus 5 8bc57e9358 Tighten the returns, give each chapter its own boss, and let the umpire talk
Five reports from Oliver's second playtest.

The parry was too generous — at 6 m of arm with a 0.34 s window the shot flew
into reach on its own, so any tap once it was vaguely close worked. 3.4 m /
0.2 s / 1.7 m reach now: a tap at 4.5 m is rejected and costs blood, 3.2 m and
in returns. The boss telegraphs in 0.5 s against the machine's 0.9 and throws
harder, because a boss should be worse than furniture.

His three balls were decoration: fanned ±1.9 m across space, so only the middle
one could ever reach you. They are a timed salvo now — three shots 0.42 s apart,
each aimed at where you are at that moment. hurtGrace still caps a whole volley
at one point of damage, which is what it is for.

Every chapter was the same boss. Three designs cycling now, each with its own
sheet, stride, throw pose, wrecked skin and wrecked-throw skin: the
GROUNDSKEEPER, the LINE JUDGE, the CHAIR UMPIRE with his chair strapped on. The
chapter card names them. Designs are data in BOSS_ART, so a fourth is a sheet
plus a row.

Healing announces itself with a first-aid tin and a red cross instead of a 1.4 s
flash. And 14 new umpire lines: ten sarcastic asides on a jittered timer, four
hooked to specific beats. He still has not acknowledged that the match became a
massacre, so he keeps officiating it.

Two traps found and written into the pipeline notes. Sprite loads now carry
?v=SPRV, because python http.server sends no cache headers and the browser
caches 404s — a name added before its PNG exists stays permanently missing for
that browser. And a scripted replace that silently didn't match left drawZombie
on the old hardcoded boss names while everything else moved to BOSS_ART; the
built-in fallback hid it perfectly, all four designs rendering as design 1 with
no error. Assert your rewiring edits matched.

Tennis untouched: 30 tapped points, 50 hits / 0 whiffs. 45-game soak clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:56:00 +02:00

43 KiB
Raw Blame History

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-30, after Oliver's SECOND playtest of the boss/ranged build. Both playtest rounds are closed at the bottom. Step 6 (crowd turning, ball-boy catcher) stays PARKED by his decision. 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_start name splatter-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. Bump main.js?v=N in index.html every time or phones keep the old build. The Dockerfile must also COPY any new top-level file (it currently ships index.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. Two runtime-only import cycles (documented in headers): input↔gameplay, zombies↔gameplay.

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.modal is 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 — only idleT ticks for the blink. showModal/dismissModal in gameplay.js; drawModal is the LAST thing render draws. Dismissal must swallow the gesture: input.js touchstart returns on it, keydown returns on it, and the pointerdown dismisser is registered only on pointer: 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 over TUNE.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 on easeOutBack; every x is Math.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 no in field draws fully landed, so older/test call sites still work. While m.in < 1, dismissModal returns 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 on G.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 chips TUNE.dmgPellet = 0.34 of a point); they bank on zb.chip and only WHOLE points come off zb.hp, because the wound skins and bleed() 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 touching zb, because killZombie splices 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. spawnRacketZombie scales hp with TUNE.racketHpPerGame and stores hpMax. Two places switch on it and they MUST stay in step: drawZombie's skins (<= 0.34 headless z_cw2, <= 0.67 torn-arm z_cw1) and bleed()'s wound anchors. The old hardcoded hp === 2 left 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 from spawnRacketZombie) 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 chip G.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.hurtGrace is 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. drawPlayerWounds paints procedural spatter at FIXED anchors (fixed so it cannot crawl frame to frame), bleedPlayer() adds the live trickle, and G.hurtFlash washes 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 from ball (there is only ever one of those and it is yours) and from extras (also yours, and they kill zombies). Three outcomes, in this order: a swing inside the volley window RETURNS it — as an extras clone at TUNE.parryPow, so "a parry is a real weapon" costs nothing to build; meleeSwipe merely bats it away; reaching you unanswered costs hp. The swish path is not optional politeness — in pretoss the 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 take wound() 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 and endBossFight both decided "what happens next" in the same frame and clobbered each other.
  • blocksClear decides 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. keyPressed checks shots for anything within TUNE.parryArm FIRST and turns the tap into a swing if so. Before that, the parry sat behind the phase === 'rally' branch at the bottom of keyPressed — and splatter is in pretoss nearly 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/parryWindow are 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 inside parryArm, tap after it has passed, or don't tap. parryWindow must stay <= swingWindup + swingWindow * VOLLEY.window (0.348 s) or update()'s swing clock expires before the window closes. meleeSwipe deliberately does NOT touch shots: it runs first in keyPressed, 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 set hitT too.
  • 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 a drawPlayerWounds overlay broke it.
  • Boss designs are DATA: BOSS_ART in config.js, one row per chapter, cycling. Each row is { name, walk: [2], wounds: [...], throws: [...] }; spawnChapterBoss stamps the row onto the body as zb.art and drawZombie reads only that. Damage picks a wound stage as a fraction of hpMax spread over however many stages the design HAS, and throws is 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).
  • UI in 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. Bare monospace resolves to Courier, a slab SERIF — that was the "generic serif" bug, not taste. Never hand-write ctx.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 from proceedAfterClear() — the one beat where the court is guaranteed empty. The game beat it interrupts is parked in G.afterDuel and 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.mgT in particular must keep counting down in EVERY phase (it used to tick only during rally, so a duel calling setupPoint() stranded it above zero forever = permanent lock).
  • Animation frames step off DISTANCE COVERED, never elapsed time — boy.dist for ball boys, zb.z / TUNE.bruteStride for brutes. Time-driven cycles moonwalk the moment speed and frame rate disagree. Sprite heights follow the PNG (img.height / SPRITE_H) because _slice.py common-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 over zombies[] so it is safe inside a kill loop; burnTick() returns true when the fire killed the body and the caller MUST continue. 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 the if (!zb) continue guards and iterate backwards.
  • The ball boy is ONE slot. sendBoy never 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 via jobs. mopRound walks him to a stain, holds him there for TUNE.boyMopDwell while 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_crowd seated / x_crowd2 arms up), per column on its own phase, with no vertical offset. A fractional dy on 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.png is 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 below CONTACT.minY is past you.
  • Aim magnet: flickTarget picks the best-ALIGNED body, with TUNE.aimTie of slack in which a nearer one still wins, so a straight-ahead lane still eats the nearest body first. aimTie: 1 restores the old "nearest along the lane always wins" for A/B.
  • TUNE.fps 60 — 30 was vetoed on feel.
  • The player only walks during rally. Never between points. Letting him walk during the 'over' beats and pretoss was 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 (held in updateZombies), never by handing back movement.
  • The player must always be ON SCREEN. P_BOUNDS.x is 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.15 first — the Browser pane reports pointer: 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 = 0 to 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 stash the 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, mgT back 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 during G.clearing makes the hand-clear un-completable and reads as a 30-minute stall; and reviving the player after each eaten inflates gore past its cap, because the death push (zombies.js, the eatenR hit) is the one uncapped gore.push in the file — fine at one death per run, not at 260. Respect clearing, and either end the soak on death or set TUNE.eatenR = 0 to make him untouchable.
  • Never trust a single sample. aiFlub is 9%; a 20/24 run is normal noise. Use 150.

Asset pipeline

  1. generate-image skill (needs OPENROUTER_API_KEY in the gitignored repo .env; run with the scratchpad venv python — system python lacks requests). Passing the existing sheet as --input (edit mode) nails the style and is how every matching sheet here was made.
  2. 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".
  3. 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. --largest keeps only the main blob.
  4. Standalone art (logo, popup panels) uses assets/_key.py instead: chroma-key + crop to the content bbox + optional downscale, no frame splitting, no forced height.
  5. Register new names in render.js's SPR preload array.
  6. New sprite not showing up? Bump SPRV in render.js. Sprite loads go through assets/<n>.png?v=SPRV for 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 the SPR list before its PNG exists, load the page once, and that browser treats the sprite as permanently missing while drawZombie silently falls back to another design. Bump SPRV alongside main.js?v=N.
  7. When a scripted str.replace edit is meant to rewire logic, ASSERT it matched. A silent no-op replace left drawZombie on 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. drawImage instrumentation 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); };
    
  8. NEVER let two sprite names differ only by CASE. macOS is case-insensitive: writing assets/z_B1.png for the chapter boss silently OVERWROTE the 'b' shambler's assets/z_b1.png (and z_B2 ate z_b2), so a 3-metre boss started showing up in the shambler crowd and git reported the shambler frames as "modified" rather than reporting a new file. ls shows only the ORIGINAL lowercase name, which is why it is easy to miss. Boss frames are z_boss* now. Use all-lowercase, whole-word names.

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.py reports "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.

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_start only helps if the port actually changes, and holding 8641 with a throwaway socket did not work reliably. What DOES work:
    await 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'})));
    
    then reload the page. Always sanity-check a just-added field before trusting any result — and note the trap: if state.js is stale but gameplay.js is fresh, a new G field materialises as NaN (undefined + dt), which reads exactly like a logic bug.
  • drawChar mirrors sprites by ch.facing — any stance that must not mirror needs facing reset.
  • Synthetic touch tests: input.js only sets stick.moved in 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.

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: explode only wounded 'r'. This is what isBoss() now exists to prevent, and it is the second time the same class of bug has shipped.
  • The machine's machHp did 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_MUL had 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 holds z <= COURT.svcFar = 5.5, so they are always on opposite sides of the net and meleeRange 1.7 can never span it. Don't "fix" an unreachable path.
  • A sub-threshold ball still passes through him on purpose so racketAI can 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; zSpawnMin is 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 is zSpeedPerGame.
  • 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.changeover in updateZombies stops every zombie's z advance, 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. expirePose only ever expired swing/serve; 'run' was the pose nothing reset, and P.runT stops advancing outside the movement block, so a beat that began mid-stride welded him to one run frame. An else if (P.pose === 'run') on the movement block fixes it for every phase at once.
  • The on-screen clamp (see Settled feel decisions) — xVis already 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.