The entrance is no longer a one-off on the boot: dealStage re-arms the class at the funnel every stage change already passes, so the title screen flies its plaque and buttons in whenever you arrive at it. The sub-menus join in — their header, content and footer arrive one after the other instead of the panel dropping in as one slab — and the poster wall deals its three rooms rather than its list, since a moving child inside a moving parent starts at twice the offset and reads as a stutter. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
415 KiB
PROJECT — Splatter Tennis
This file is the only planning document (renamed from HANDOFF.md, 2026-08-11 — it was never a
handoff, it is the project's living notebook). 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.
THE SUITE IS DETERMINISTIC ON PURPOSE (Math.random is seeded at the top of test.js). It is a
Dockerfile build stage, so a red run blocks the deploy — which makes a test that fails one run in
five worse than no gate, because it trains you to re-run it. Two drill tests did exactly that and
the pipeline caught what sixty local runs had not: one asserted on a random batch composition, the
other on a SNAPSHOT of a population that legitimately empties (an eater crosses the whole court in
~24 s and despawns, so "no eaters on court at 40 s" is normal, not a failure). Fuzz with
SEED=1234 node test.js whenever you touch anything probabilistic — and beware the cost of one
fixed seed: a guarded test can silently take its early-out every run and quietly stop testing
anything, so if you add a return for an awkward deal, check the body still runs.
node test.js must pass before any commit — it drives the real game modules headless and pins
the state-machine contracts that have historically produced the show-stoppers. The Dockerfile runs
it as a build stage, so a red suite cannot deploy — but finding out from Coolify is the slow way.
Last updated 2026-08-21 (THE CROWD: rolled again, in far more detail, once for all three courts;
every frame is the seated sheet plus that pose's raised arms and nothing else, the sheet is a real
three-frame animation sheet, and the stand animates ONE SPECTATOR AT A TIME off src/crowd.js. The wave, the idle fidget, paparazzi that scale with the
moment, and an aww that still claps. See "THE CROWD, ROLLED AGAIN". Before that 2026-08-20 (THE SHELL: a Capacitor iOS project, haptics on the existing sfx call
sites, portrait enforced in Info.plist, and swipe-left-to-go-back in the menus — the web build
unchanged and still primary. See "THE SHELL"). Before that, same day: the friend across the net gets his OWN sheets — the player's kit, drawn
from the front, so his pick dyes the same garment yours does; the setup screen shows that front too.
See "THE FRIEND ACROSS THE NET WEARS YOUR KIT". Before that 2026-08-18: the store model in LATER revised: free download + €2.99 unlock-everything
IAP with the free-tier gates, superseding paid-upfront; before that 2026-08-15, two sessions: the
finetuning pass — gore opens in the gore, the menu theme
comes back, one world board per mode, and the barrel economy cut — over the batch below: six new
opponent families and an 18-rung ladder, the
tournament DRAW screen, three netplay fixes off Oliver's cellular report, the night court, and
camera flashes in the stand). 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(server.pyon :8641,.claude/launch.json). It is a real server now, nothttp.server— editingserver.pyneeds a preview RESTART, and a stale Python process looks exactly like a broken feature (it cost a debugging round). - Deploy: push to
git@gitea.wuwi.one:wuwi/splatter-tennis.git(main) → Coolify webhook builds the Dockerfile. 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,server.py,src/,assets/—test.jsonly feeds the test stage and is deliberately not in the served image). - The image is
python:3.12-alpinerunningserver.py, no longernginx:alpine. The world board needs one process that can WRITE, and nginx is not one; everything the nginx conf did here (no-cache so a deploy lands on a plain reload, andapplication/manifest+json, without which Chrome refuses to install the homescreen app) is a few lines inserver.py. Standard library only — no pip, no requirements file. /dataMUST be a persistent volume (Coolify → the resource → Storages → add a volume mount at/data). Scores live in/data/scores.json; with no volume that file sits in the container's writable layer and every deploy silently resets the leaderboard to empty, which looks exactly like the feature working until the first time you push. Verified both ways with a local rebuild. Staging and prod are separate resources, so they get separate volumes and separate boards — which is what you want, or staging tests would land on the real ladder.- Staging: the
stagingbranch deploys to its own subdomain (own Coolify resource, same repo, branchstaging, same Dockerfile). Test there first, then merge tomainfor the testteam. The branch carries NO diff against main, so it fast-forwards and merges freely — keep it that way. A subdomain, not a subfolder, so staging gets its own origin and can't stomp the prod localStorage. Two Gitea webhooks, branch-filteredmainandstaging, both hitting the same Coolify URL (it matches the resource by repo AND branch). A filtered hook's "Test Delivery" always looks dead — Gitea fakes a push on the default branch and its own filter drops it. Push if you want to test. - 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 (game code in src/; index.html, server.py and test.js at the 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 · beats.js the beat machinery: every mode
transition (duel/horde/boss/changeover/drill/proceedAfterClear) — ONE MODE OWNS THE COURT, and
each start* enforces that itself; a new mode starts there and refuses there · drill.js the
coaching drill's own clock, bodies and rules (its transitions live in beats.js like every other
mode's) · 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, scoreboard test-mode taps,
window.T test surface (the umpire-tap cheat is removed — the testteam found it;
T.forceZombies() is the console replacement) · test.js the headless regression suite
(node test.js, also a Dockerfile build stage — a red suite cannot deploy).
FIVE runtime-only import cycles (documented in headers): input↔gameplay, zombies↔gameplay,
state↔zombies (sendBoy reads gore to decide whether a mopping round is worth walking out for),
beats↔gameplay and beats↔zombies. All 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. POLICY (2026-08-08, testgroup round): a modal may only fire on a guaranteed-calm court (duel intro, boss walk-on, post-duel healing, test mode, the horde's first-ever arrival). Anything that can land on a live court announces as a CARD or a flash — the freeze itself is safe (measured: a body moves 0.000 m behind a modal), but the stolen tap and the broken rhythm cost runs.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.m.holdis an optional self-dismiss clock (seconds, ticked only oncem.inhas landed): the modal announces, holds the frozen court, and lets go by itself. The chapter-boss walk-on is the case it exists for — an announcement whose job is to PREPARE the player, where demanding a tap to start a fight he did not ask for would be the control steal the popup policy exists to prevent. A tap still skips it. Every other modal has noholdand waits for the player, as before. 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. woundStage(zb, n)in config.js is the ONE rule for how damage READS. 0 = unhurt, 1..n = the skins, spread evenly overhpMax. There used to be three rules: the brute's hardcoded stage array indexed by absolute hp, a single skin behind'abd'.includes(...), and the boss's fraction-of-hpMax. The boss's rule reproduces the other two EXACTLY (5 hp over 4 skins is one skin per hit; 2 hp over 1 skin is the shambler) and keeps working when hp is not one-per-skin — which the ball boy (3 hp, 2 skins), bouncer (6/3) and roller man (9/4) all need. So a new multi-hit body is now one row inconfig.ZWOUND(healthiest first,…bstride twin looked up automatically) and NO code.bleed()'s anchors are read through the same call and are deliberately the same length as that variant's ZWOUND row, so the blood and the drawing it comes out of cannot drift apart — they used to be written by hand against different constants. 'r' and 'B' keep their own skins (the boss's on its design row) but go through the samewoundStage.- A chapter boss's MOVEMENT is data on its
BOSS_ARTrow (move, plus optionalhpMul,speedMul,volley,gap,spread,cycle,wind,shot), andbossAIin zombies.js is the only place it moves. Every field is optional, so a row carrying nothing but art behaves exactly as a boss did before there were any. Three things that are load-bearing and were each a bug first: (a) the generic forward step at the top of the body loop SKIPS'B'— bossAI owns both axes, and leaving it in advanced the boss twice per frame; (b) the stride steps offzb.dist(ground covered on both axes), notzb.z, or a boss strafing sideways slides with its feet frozen; (c) a boss holds a z BAND (bossZMin..bossZMax) except while charging — uncapped, a fight ended by the boss simply arriving after about a minute, and every extra hp made that MORE likely, not less. No style may rely on the player moving to create its own motion: in splatter he is rooted inpretossfor nearly all of a boss fight, so "close on the player's x" arrives, stops, and is static for the rest of the fight. Every style moves under its own power and only BIASES toward him. 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 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()(beats.js) — 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). - THE CROWD SHEET IS AN ANIMATION SHEET, and every frame of it is frame 0 plus arms. One file
per court,
FRAMESframes stacked vertically: 0 seated (what the cached backdrop draws), 1 arms raised high, 2 hands together in front of the face.assets/_crowdpass.pybuilds 1 and 2 by copying, out of two further rolls, ONLY the pixels that were STAND in the seated roll and became PERSON — about 1.2% of the sheet each. Not a raiser's face, not his shirt, not his seat: those are frame 0's own pixels and cannot drift.src/crowd.js(generated) boxes each of the 134 spectators who can move, with a mask of which raised frames they appear in — 28 own both, and those 28 cycle between the two poses, which is a clap rather than an appearance. render.js animates one spectator at a time out of that table. It was NOT true before 2026-08-21: the clap sheet was a separate roll differing from the seated one in 13-16% of its pixels, everywhere, so the animation re-hashed the crowd rather than raising arms — Oliver, "the crowd wobbles with a client side animation right now.. that feels odd." Never hand-edit a crowd frame, and never editsrc/crowd.js. Re-roll with_crowdroll.py, then re-run_crowdpass.py, which rebuilds every frame, all three court grades and the table together. Draws stay integer-aligned 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 APPLAUSE HAS TO HAPPEN WHERE THE HUD IS NOT. A raise is only detectable where there is
empty stand above somebody's head — that is how the arms are told apart from the roll's drift —
and the sheet only offers that above the top row of each seating block. One of those rows sits at
the very top of the picture, which is exactly where the scoreboard is: measured, 31 of 134 raisers
were being drawn under the board or the blood panel. Oliver: "in the upper parts of the crowd there
seems to be a lot more action going on, but I can barely see it because the hud is covering it."
Two rules now hold the line, and BOTH are needed — the second alone would just delete the
applause:
- the arms-up rolls are asked for standers in the lower two thirds only (
_crowdroll.py); _crowdpass.pydrops any raiser whose box centre falls under the board or the blood panel (HUD, in portrait logical px — landscape has a shorter band, so the rule is conservative there rather than over-eager). Result: 92 of 101 raisers below the board's bottom edge, against 103 of 134 before.
- the arms-up rolls are asked for standers in the lower two thirds only (
- A raise must be ARMS OVER STAND, and
_crowdpass.pytests all three words. The rolls repaint clothing as well as posing people, so "was stand, is now person" also catches a cream shirt that came back a shade brighter (PALE_MAX) and a genuine arm drawn over something already bright, where raising him DELETES a white block (COVER_MAX). Both flash exactly as hard as a real error would. - A raiser is ~8x6 logical px, so a pose that appears and holds is invisible. What reads as
applause is the CYCLE — arms high, hands together, up, down, at about 3.5 Hz — and how many people
are doing it at once (
crowd.pop). Oliver on the first per-person cut: "when clapping I barely see any movement at all now." - The three courts are ONE crowd, graded.
x_crowd,x_crowd_candx_crowd_hare the same roll under three per-channel histogram matches onto the courts' old palettes, applied to the whole stack at once so the frames cannot drift apart in colour. They were three separate rolls until 2026-08-21, and the cost of that was exactly what Oliver reported: "the whole thing somehow does not translate well to the other courts, there the difference is barely visible." - The stand sits BACK, and the two dims do NOT stack.
standDimiscourt.dim ?? TUNE.standBackplus the splatter rot — a court that names its own darkness REPLACES the default instead of adding to it. Adding was the first cut and it made two courts unreadable: centre court's crowd sheet is a night-lit navy to begin with and the night court dims 0.5 on top of the hard-court sheet, so both went to mud (Oliver: "it is too dim to see anything on the centre court and the dark court"). Centre court therefore carriesdim: 0.06of its own, and its sheet is also graded off a LIFTED target (gamma 0.72 in_crowdpass.py) because the old centre-court crowd it matches sat at a mean luminance of 39 against clay's 96 — dark enough to throw the new roll's detail away before the dim even ran. On the default itself: "all of the art could be colored a little more dim as it is perspective wise farther in the background and should not steal too much attention."assets/_sheet_crowd1.pngis the pre-brightening original of the FIRST crowd, kept for history.
Settled feel decisions — do not regress silently
- ANYTHING DRAWN OVER THE CACHED STAND MUST CARRY THE STAND'S OWN TREATMENTS. There are two —
standDimandtopFade— and a raiser box drawn with one of them missing lights up as a bright rectangle every time that spectator stands. That is what the "white flashing in the upper right corner of the crowd" was (2026-08-21): the top fade was baked into the backdrop and the live raisers were not repainting it. Both now come from one function each, called frompaintBackdropand fromdrawRaisers. The gradient is built in USER space, so filling any sub-rect with it picks up the right alpha for free. Two copies of either number is the same bug waiting again. - The frame is dark at BOTH ends. The run-off apron already took the bottom of the screen down
to near-black below the near baseline;
TUNE.standTopdoes the same at the top, so the stand fades UP out of the dark instead of being cut off at y = 0. Oliver, 2026-08-21: "the bottom of the screen falls off into a black tone.. please make the top come out of a black tone." It also gives the scoreboard something to sit on. - Every HUD element on top of the picture carries a drop shadow (
render.hudShadow, and the same numbers in CSS on the quit chip): the board, the game/sets panel, the BLOOD panel and each rack tile. They sit on a crowd, and half the overlay lifting off the backdrop while the other half does not is worse than neither. Canvas shadow offsets are NOT scaled by the transform — they are device pixels — sohudShadowmultiplies byRES, or a 3 px shadow reads as three quarters of a logical pixel on a 4x phone. It is sticky state: turn it off the moment the element is drawn. - Far bodies draw off a flattened scale curve; the camera holds still.
TUNE.farK0.25 (render.jsfarB): the court projects true, bodies grow toward the far baseline (~20% at the back), anchored so the near player never changes. Oliver: "new perspective is really good — helped a ton." The ball-follow lean built alongside it (farCamY/farCamZ) was vetoed the same day — "the camera movement is distracting" — and sits at 0 like the aimCam pair: every camera-motion experiment on this game has now lost to a still camera. - 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.xLimit()in gameplay — the camera limit, floored at the sideline so every legal ball stays catchable. The walk and the sidestep share it. - The auto-assist recovers in DEPTH ONLY in splatter — it must never undo a sidestep. The
recovery target used to be
HOME.P, x included, so firing walked you back toward the centre line: measured, a step out to 1.94 m was dragged to 0.77 m over one ball's flight, i.e. most of a dodge you had spent a cooldown on, handed back for free. That is the "welded in place" complaint the sidestep exists to answer, arriving slowly instead of all at once. Tennis keeps the full recovery — recovering to centre is the real sport's footwork and there is no sidestep there to protect. (Oliver: "the dodge swipe should not reset to the middle when firing".) - The sidestep is how you move outside
rally, and it is not a walk. One committed step (TUNE.dodgeDist) on a cooldown, carryingTUNE.dodgeIFrameseconds that beat BOTH damage channels — the chip damage inhurtPlayerand the instant-death contact test. It exists because a boss fight is spent inpretoss, so the rule above left you welded to the spot for the whole fight and Oliver died at every chapter boss without ever being able to move. - Which way you leave the deadzone IS the gesture. Decided once, at the
DEADcrossing, and never revisited for that touch: down-ish = the slingshot, sideways = the sidestep. This is how the new gesture stays inside "one gesture means one thing" — there is no timing window and no hidden mode, only the direction you actually moved. The finger-steer modes (horde, burst) are untouched:stick.aimableis false there, so the drag is still the absolute steer it always was. - Two type registers, and the register IS the message's category. Silkscreen for the HUD, the
rack, the control hints and
RESUMING IN— things you read at leisure. Bangers (B()in render) forSET,CHAPTER,EPIC!, the praise words and every free-floating flash — things shouted at you. EVERY floating string goes throughshadowText, both faces, because none of them has a panel and all of them have to survive the crowd, sunlit clay and blood behind them. The scoreboard and the rack are the two exceptions: they sit on their own dark panels, and a 1 px shadow under 6-8 px type on a flat dark fill only smears it. - Praise is a narrow window, not a ceiling.
pullQualitySATURATES:pullFracclamps atpullFull, so every pull past 130 px is exactly quality 1.0 and the oldquality > 0.999test praised nearly every serve — "it displays perfect a lot even when nothing special happened".hitServetakesperfectas its own argument now: the toss ritual still earns it on the 45 msperfectMswindow, the slingshot earns it by STOPPING inpullFull..pullFull*perfectBand. The draw clicks brighter when you enter that band and goes quiet when you overshoot — without that audible target it would be invisible skill. The word itself rotates throughPRAISE(cycled, not random: the same word twice running reads as a stuck HUD element, not as praise) and sits behindTUNE.praiseEvery, the same shared-cooldown model the umpire's asides use — you LEARN to hit the band, and then a per-serve word is noise again whatever it says. The click is the feedback for a good pull; the word is garnish. - The free-floating slot is 18px, not card-sized. Half of what lands in it (the praise words,
RETURNED,DOUBLE KILL) is a footnote to something you already watched happen. Sharing the loud FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone.
Session log: THE SEASON, PHASES 1-4 — the front door, the record, the rooms, the season beat (2026-08-31, STAGING ONLY)
All four phases of the plan below, plus the overlay consolidation Oliver asked for in the same breath ("die modals haben manchmal 2 boxen manchmal eine manchmal keine... der tap wird zu einem echten button im footer der box... zudem noch das run-ende, hier bitte auch mehr struktur").
THE MODAL IS ONE BOX (render.drawModal). Three drawers — the plain announcement, the rules
card, the walk-on — became one renderer with slots: a MASTHEAD (art beside the title, with the
body's strap under it), a stack of ROWS separated by hairlines (statement over consequence, the
split figure of his level against yours, the blood pool as pips), and a FOOTER BUTTON. The payload
is untouched: title/body/icon/rules/foe are the same fields beats.js has always sent, so the
suite and every call site are unaffected — this is a layout. Two consequences worth knowing:
- The entrance is now ONE object flying in from the right. The old per-element stagger would tear a box apart, and a box that assembles itself out of flying pieces is not a box.
- The button says what HAPPENS (
PLAY ON/GOT IT/WALK ON,m.btnoverrides) instead of what gesture to make, and it appears only once the box has landed. A tap anywhere still dismisses — the button is the affordance, not a hit target. A modal you could only leave through one 26px strip would be a worse wall than the one we had.
THE RUN-END WEARS THE SAME BOX, AND HANDS OVER. Headline and the run's line, then the xp it
paid, then the mark, then THE FIXTURE with its buttons — ENTER THE DRAW beside PLAY AGAIN
(or THE CLUBHOUSE when the run just played was the cup). render.endHit(nx, ny) gives the
rects back to main.maybeRestart, which is where the tap already lived; a tap anywhere else is
the old behaviour exactly, so nothing that worked before stopped working. Netplay gets ONE door
(the clubhouse): dealing a solo cup into a session whose socket is still tearing down is not a
handover, it is a bug waiting for a phone verdict.
THE CUP'S OWN EXIT IS A CEREMONY. OUT IN THE QUARTER-FINAL. over YOUR BEST STANDS: THE SEMI-FINAL (or THE BOARD NOTES IT when the attempt IS the new record). The board's felled count
is suppressed there — two bests answering different questions in one box was the wall of text this
pass exists to remove.
THE RECORD (profile.cups / profile.out, recordCup, cupBest). Two small fields: rounds
ever reached per league, and the rung of the body that ended the last attempt. Written by the two
places a cup run can end (zombies.killPlayer, beats.cupWon), mirrored server-side beside the
xp (server.clean_profile re-derives them like every other field), merged FORWARD on pull. Shown
on the plaque (which stopped repeating "win the cup to advance" — the fixture button below says
that), in the league panel, and in the summary. Training reps write nothing: a rep is not an
attempt.
THE FRONT DOOR IS THE FIXTURE. PLAY MATCH became LEAGUE 3 · THE CUP / 4 ROUNDS · ROUND ONE: THE STRAWBERRY SELLER and enters the draw directly — the cup has no court to pick and no opponent
to choose, so the setup screen there was a confirmation page with nothing on it to confirm. The
poster wall moved one tap down behind OTHER GAMES. config.cupFixture is the single descriptor
all three screens read, so the splash, the summary and the promotion cannot drift apart on its
wording.
THE ROOMS. The wall deals three named groups now — THE SEASON (the cup, alone), THE TRAINING
(the climb, the training court), OUT OF COMPETITION (gore, classic, a friend) — and the cup left
the ZOMBIE TENNIS pocket for the top. That also emptied the second step of everything except
CLASSIC's difficulty chips. MENU rows with a group key are headings, not posters.
THE REVANCHE. The training list puts the body that knocked you out of the cup FIRST, in a red
tile that says PUT YOU OUT OF THE CUP. One remembered rung turns a shelf of eighteen equal tiles
into the obvious answer to the last defeat — the cheapest coupling of Beiwerk to canon we have.
THE SEASON BEAT rides inside the promotion (no new screen): NEXT: LEAGUE 4 · THE CUP over
its rounds and its first body — or, for the champion, the title he now has to hold.
Tests: two added (127 total) — the fixture descriptor against every league (including the
champion's defence), and the record's forward-only write with the eliminator it names. freshSplatter
resets cups/out for the same reason it resets the league: a cup ending writes them for real.
THE WALL'S HEADINGS BECAME SECTIONS (same day, Oliver: "sind sehr klein, vielleicht koennen wir
daraus richtige sektionen machen"). Each group is a <section> with its posters INSIDE it: the
announcement face one size under the screen title, a rule closing the header, an inset band down
the left, and the canonical room wearing the primary's colour. A heading floating over a stack is
a label; the wall needed rooms.
THE MEASURES (same day, Oliver's second pass over the new language). The modal box is narrower
(244) and its button spans the full inner width; MODAL_PAD 14 / MODAL_GAP 13 / MODAL_BTN 30
with a MODAL_FOOT margin above the footer rule — the 9px gaps read as "gedrungen", the summary
worst of all. The xp bar is 7px and its gradient rides the FILL, not the track: spanning the
ramp across the whole bar meant an early level only ever saw one flat green inch of it. An
ILLUSTRATED announcement now puts its copy beside the picture under the headline (the strap
layout the walk-on already used) — but only when it fits the narrow column without wrapping, and
the art gives back 10px of height to make room. Anything longer falls back to a row.
The section headings left the announcement face to the posters (it read as a second card headline) and dropped the rule and the left band; air does the grouping. On the splash, OTHER GAMES has its gap to the stats row again, and the boot shows nothing but the court, the title and the loading bar — the plaque's skeleton was still a plaque telling a level-25 player he was level 1. It keeps its SPACE (visibility, not display) so the title does not slide.
THE TWO DOORS STACK. Side by side they gave a 100px label a 120px half — ten pixels of air,
which reads as a squeezed chip, not a button. Where there is height (H >= 360, every phone in
portrait) the summary stacks them full width, filled primary over ghost secondary — the shape the
modal footer and the splash already use. A short LANDSCAPE cannot stack without overflowing the
canvas, so there the box grows to 344 instead and the halves come out comfortable. One conditional,
both cases honest.
BOTH DOORS SAY WHICH IS WHICH. ENTER THE DRAW retired (splash setup + summary): a "draw" is
tennis for the field of entrants, and nothing has been drawn since the rounds went deterministic
on 2026-08-29 — Oliver having to ask what it meant settled it. It is ENTER THE CUP now. And the
door beside it lost its subject problem: an unqualified PLAY AGAIN next to it read as a second
"start something", so it names its mode — GORE AGAIN / CLIMB AGAIN / CLASSIC AGAIN /
TRAIN AGAIN, derived in the summary from G.classic / G.match.train / G.match.
THE BOOT STATE SHIPS IN THE MARKUP. Hiding the plaque behind #splash.busy was not enough:
this is a MODULE script, so the browser paints index.html — placeholders and all — before main.js
can add the class, and Oliver kept seeing LEVEL 1 · LEAGUE 1 snap to the real values. class="busy"
is in the HTML now and main.js only ever takes it off. The keyboard-hint strip hides with it
(body:has(#splash.busy) #hint), so the loading screen really is court, title and bar.
THE MENU DEALS ITSELF IN. The canvas modals have always arrived on an easeOutBack overshoot;
every menu screen does the same now — on EVERY arrival, not just the boot (Oliver, twice: "immer
reinfliegen" and "auch für die untermenüs"). @keyframes dealIn flies an element in from the left
on that overshoot, and main.dealStage() re-arms the deal class on #intro at the one funnel
every stage change passes (remove, force a reflow, add — an animation whose class never left
cannot restart, and CSS has no replay switch).
- THE SPLASH deals its own five: plaque, fixture, OTHER GAMES, the pair, the build line, 70ms apart.
- EVERY OTHER SCREEN deals its BANDS — header, content, footer — through
#intro.deal > .card > div:not(#splash) > *with an nth-child stagger. The panel's old whole-slabdropretired: a slab says "a screen appeared", a deal says "the machine is laying this out for you". - THE POSTER WALL deals its three ROOMS instead of its list, and the list itself stops animating — a moving child inside a moving parent starts at twice the offset and reads as a stutter.
prefers-reduced-motionturns all of it off, and the splash holds its deal until the boot gate lifts (there is nothing to deal while the loading state is up).
EVERY SCREEN OPENS AT ITS TOP. #intro is the one scroller the menus share, so a scroll earned
on the stats screen outlived it — go back and the splash came up scrolled, buttons off screen
(Oliver). go() resets intro.scrollTop, at the single funnel every stage change already passes
through.
Open for the phone: whether the fixture button should ever go to the setup screen (it does not now), and whether the promotion's WALK ON should enter the cup instead of the clubhouse.
THE SEASON — the tournament is the story (2026-08-31, AGREED in discussion; PHASES 1-4 BUILT ON STAGING — see the log above)
Oliver's diagnosis, and it is right: "alles ist irgendwie beliebig... die Menüführung und die Start- und Endscreens enforcen keine Story-Line". The cause is not a missing story — THE LEAGUES already are one. It is that the story runs UNDERNEATH while a mode picker sits in front: five modes as peers on a poster wall, the goal a caption on the plaque, and every ending dropping you back on the splash with nothing handed over. The one screen that already works is the PROMOTION, because it says what you did, what is new, where you go next — that is the pattern the rest is missing.
Decisions, settled with Oliver 2026-08-31
- THE TOURNAMENT IS THE CANONICAL PATH. It is the only thing that moves the career. Every other mode is Beiwerk by design and stays that way — no second promotion route, ever.
- A CUP ATTEMPT IS ONE EVENING. No resume across sessions, and no seeding: reaching the semi-final once does NOT let a later attempt start there. Elimination sends you back to round one. That price is what makes the cup a test rather than a queue — explicitly weighed against the friendlier alternative and rejected.
- Classic tennis stays Beiwerk with no narrative role. Gore and netplay likewise: "ausser Konkurrenz" is their ROLE, not a demotion.
- Guidance is structural, never a lock. The leagues' rule holds: nothing that is playable today gets sealed behind progress to manufacture a path. The player is POINTED, not fenced — two taps still reach anything.
- The story is carried by structure and screens, not by more prose. The copy audit already proved the failure mode: rules buried in modal text do not land. No narrator dumps.
The spine — five pieces
- THE FRONT DOOR IS THE FIXTURE. The splash's primary button stops saying PLAY MATCH and names the next cup fixture instead ("LIGA 3 · THE CUP — FOUR ROUNDS · ROUND ONE: THE GROUNDSKEEPER"), entering the draw directly. The poster wall moves one level down behind a secondary door. One tap to the canon; one extra tap to browse.
- THE CLUB REMEMBERS THE ATTEMPTS. Two small profile fields per league: the best round reached, and who eliminated you last. Shown on the plaque and the league panel ("BEST: SEMI-FINAL"). A lost cup stops being a null event and becomes a line on the board — this is what turns repetition into an arc, and it is the cheapest piece here.
- BEIWERK IS DESCRIBED IN CUP TERMS. The poster wall groups into DIE SAISON (the cup) / DAS TRAINING (the climb, the training court, the drill) / AUSSER KONKURRENZ (gore, classic, netplay), so the menu itself answers "what does this do for me?". The strongest coupling: after an elimination the training court preselects the body that knocked you out — one remembered rung and one line of copy turn a generic practice mode into the answer to the last defeat.
- EVERY ENDING POINTS BACK. Non-cup endings close on the fixture line and its button. The cup elimination gets its own small ceremony (the board notes how far you came) instead of the generic death summary.
- THE SEASON OPENS INSIDE THE PROMOTION. No new screen: the promo screen gains a second beat naming what waits in the new league (rounds, who stands at the bottom of the band). Ceremony without another tap between the player and the court.
The phases
- Phase 1 — the front door. The fixture line + button on the splash, and the same handover on
every ending. Presentation only; reads
cupRoundsForand the profile, adds no state. - Phase 2 — the record. The two profile fields, their display on plaque and league panel, and the elimination ceremony.
- Phase 3 — the rooms. Poster-wall grouping and the training-court preselect.
- Phase 4 — the season beat inside the promotion screen.
Open for the build (not decisions): the exact fixture wording; whether the fixture button skips the cup setup screen entirely (it has no court choice anyway); how the record reads at league 5; and whether the elimination ceremony is its own screen or a variant of the run-end summary.
Session log: the copy audit — every modal says what it means (2026-08-29, Oliver's ask)
A sweep over every modal/card message for decoding-required copy, four finds fixed:
- The arcade's rules card said DEATH MATCH — a leftover of the era when the solo mode WAS one; the picker has said ARCADE/THE CLIMB for a day. It wears THE CLIMB now ("THE WHOLE CLUB, BOTTOM UP / ONE GAME PER BODY — HOW FAR CAN YOU GET?"); netplay keeps DEATH MATCH (there it is literally the mode) and says "WOUND YOUR FRIEND".
- The tournament card's "UP YOUR LEAGUE'S DEAD HIERARCHY" (the trigger for this audit) became "YOUR LEAGUE, ROUND BY ROUND / WIN IT ALL — MOVE UP A LEAGUE" — the payoff was never named.
- The ball-machine card never named the machine ("SWING TO RETURN — OR SWISH IT AWAY", an instruction without a subject) — "A BALL MACHINE / SWING ITS SHOTS BACK — OR SWISH THEM AWAY".
- The drill's "FAST BEATS CLOSE · STREAKS PAY" decrypted on the zoned card ("THE FASTEST BODY — NOT THE NEAREST" / "THE WRONG BODY BREAKS THE STREAK" — checked against drill.js's actual rule); the body keeps the old words for the suite.
- SUDDEN DEATH only sold the win — it names both stakes now ("LOSE: THE DEAD TAKE A GAME / AND FIVE MORE CLIMB THE FENCE", the real resolveInterlude cost). Judged fine and left alone: the boss walk-on, horde, barrel, trainer, training-court and bench copy.
Session log: the rules cards get zones too (2026-08-29, Oliver: "wichtige Infos, schwer zu erfassen")
Same disease as the walk-on, same medicine: the three mode modals (THE TOURNAMENT, DEATH MATCH,
THE TRAINING COURT) carried compound em-dash sentences edge to edge. render.drawRulesModal
(driven by m.rules: [{lead, sub, hot, pips}]): each rule is its own boxed row — the statement
in cream, the consequence beneath it DIM for information and the modal's hot colour only when it
threatens (hot), and a rule about blood shows the pool as the HUD's own pips instead of a naked
number. Entrance lanes, freeze and tap rules unchanged; title/body still carry the words.
Session log: the walk-on gets its own face (2026-08-29, Oliver: "buried in a wall of text")
The opponent intro carried six kinds of information as one modal line stack. It wears designated
zones now (render.drawFoeModal, driven by m.foe from matchOpponent): the MASTHEAD (mugshot
large on the left, the name in the announcement face, the slogan wrapped beneath — the shared
wrapText, measured against the space beside the mug), THE MEASURE (a split .figs-language panel:
ITS LEVEL boxed against YOURS, the number in the modal's hot red while the body outranks you and
the pick green once it does not), the round/lesson strip, and the blinking tap. Same modal
machinery throughout — entrance lanes (masthead from the right, the measure from the left), the
frozen court, dismiss rules. m.title/m.body still carry the same words, so the suite and any
legacy reader are untouched; the one icon-shaped assertion moved to m.foe.head.
Session log: the look stops lying (2026-08-29, Oliver: "the man with a pink shirt")
After a reload with the women's tour picked, the live-player preview showed THE MAN from behind in her kit colour — and the woman, when she did show, stood a head shorter. Two root causes:
- drawLook's fallback satisfied the retry poll.
n_idle1_f || p_idle1_f || p_idle1drew whichever loaded first and returned true — the caller's 200 ms poll stopped on the lie and the stand-in stuck. It still draws the stand-in (better than a blank) but returns done only when the WANTED frame drew. - The 96px canvas width-bound the contain fit. Both front sheets are 224 tall, but hers is 127 wide against his 114 (she reaches further — the TOURS note), so contain on 96 shrank her 10% more than him. The three look canvases are 128 wide now; both bodies are height-bound and stand equally tall.
Session log: training pays a quarter, the bar wears its endpoints (2026-08-29, the batch's two discussions, both go)
- THE TRAINING COURT PAYS A QUARTER. Zero pay made it feel pointless in a meta game (his words) — but full pay would make the no-permadeath, pick-your-body grind the optimal one. award() now quarters (rounds) training amounts instead of muting them; drill, bench and test mode stay muted. The footer says "NO RUN, NO DEATH — REPS PAY ¼ XP", the mode's one-time modal agrees, and training gained real practice value anyway since bodies train at their band's true tempo. The "NOTHING IS BANKED" half is still true: no run, no board, no ladder.
- THE BAR WEARS ITS ENDPOINTS: "20 ▓▓░░ 21" on the plaque and the card (MAX at the cap) — he read the bare bar as total progress; the two numbers say "fill toward the next level" without a word of explanation. The card keeps its words and the CAREER bar as the long form.
Session log: the licence, the peer line, the steward's vest (2026-08-29, Oliver's small-finds batch)
Three of five items from his post-merge test built (training xp and the level/xp display are under discussion):
- THE WEAPON LICENCE hardened. His catch: the duel's tin was league-gated but the BARRELS
still paid every weapon. Root fix, not a barrel patch: the league is a LICENCE and the run's
fromgates are the PACING — both must hold, intinLadder()ANDpickBarrelWeapon(). The tins' old OR (league unlocks early) just moved his complaint one screen over, so it went: below league 3 there are no lasers anywhere, ever; rockets need league 4; the gore director's tuned income curve is untouched for licensed players. Tin test rewritten to licence semantics. - THE PEER LINE: every walk-on modal now carries "LEVEL 34 — YOURS: 20" (config.foeLevel — the ladder position spread over the profile's 50 levels, display only, nothing scales off it), so you can feel whether you are that body's equal yet. His ask, verbatim.
- THE STEWARD GOT HIS VEST BACK. The tennis family was rolled off z_d1 (which wears the
orange hi-vis) but the FAMS words said "dark uniform jacket" — and the words won. The words
name the vest now ("on him in EVERY single pose"); family re-rolled through
_famroll -> _zsheet, review-cut checked, installed (SPRV 149 -> 150). z_sthead was already cut
from the gore frame and stayed byte-identical. NOTE: a full
_heads.py --installrebuilds SIX OTHER head icons differently from what is installed (script drift, br/ch/dr/gk/ma/r) — those were reverted, not shipped as a side effect; whoever touches heads next should reconcile first.
Session log: THE LEVEL IS YOU, IN THE LEAGUE — legs and window (2026-08-29, after the merge)
Oliver's deep-test question ("wie wirkt sich mein level aus? werde ich schneller, genauer?") exposed two honest gaps: the level's buffs were defensive-only (the plan's serve/accuracy dials never landed) and linearly spread over 49 levels — imperceptible where he actually plays (level 20, league 4: "autorun schlicht zu langsam, das timing wird zu tight"). His directive: gore aside, the league must reward xp SUBTLY but really; level 1 stays the untouchable baseline.
- profile.bonus() is FRONT-LOADED now: sqrt of the level fraction (the xp curve's own fast-early logic). Level 20 carries ~0.62 of each cap value; linear gave 0.39.
- THE LEAGUE PAIR, league modes only (
G.match && !G.net— classic keeps its picker, netplay stays a fair human match, gore untouched):run(cap +15%) multiplies INSIDEassistK(), so the mover's legs and intercept's reachability can never disagree (the rzChaseSpeed rule, player-side);window(cap +20%) widens trySwing's player contact window AND the update() swing-clock expiry that would otherwise close over it — and since quality is centre-normalised, the same millisecond error also scores HIGHER: quicker to the ball, cleaner when there. At level 20 that is ~+9% legs, ~+12% window. - Suite 125: level-1 identity, cap bounds, the front-load ratio, the league/netplay/classic gating — and freshSplatter zeroes xp now, because the level feeds real dials and accumulated test awards would silently buff every later test.
THE LEAGUES LAND ON MAIN (2026-08-29, end of the build day)
Oliver's phone verdict after the full staging day: "super — sehr gute session. bitte merge nach
main." Everything from the plan through the four phases and the whole polish tail (the plaque,
the climb, the deterministic cup, the boot gate) fast-forwards to main in one piece; staging and
main are identical again, as the branch rule wants. Prod grows profiles.json beside
scores.json on its /data volume on first write. The "STAGING ONLY" stamps on the logs below
are history from here down.
Session log: the boot gate learns about the profile (2026-08-29, same day)
Oliver: on a fresh load the player/build requests are still in flight while the splash is already
interactive. The existing #splash.busy gate (sprites + fonts) now ALSO waits for the profile
mirror's pull — capped at 4 s, because a dead network must never park the player on the title
(the sprite loader's own rule). While busy the PLAQUE is a skeleton: ghost crest, blank pills
where the words go, empty bar, no chevron, pointer-events: none — and the splash keyboard
shortcuts respect the gate too (they didn't). Half-true numbers neither show nor navigate; the
pull's refreshSplash repaints the real values the moment the gate lifts.
Session log: THE CUP CLIMBS — the shuffle retires too (2026-08-29, same day, STAGING ONLY)
Oliver, straight after the draw screen went: "warum sollten wir noch mischen? wäre es nicht gut
von leicht nach schwer zu spielen?" — right again. Without the screen the shuffle was invisible
novelty, and a bad roll paired the strongest challenger with the one-game round one, INVERTING
the curve the whole game is tuned around ("starts easy, gets harder" is literally test-pinned).
cupRoundsFor is deterministic now: one band position per round, ascending, the final the top —
and the setup's league panel is thereby an exact preview of the tournament order. cupDraw
collapsed to a read; cup.draw/cup.played are gone (startMatch keeps the dead draw parameter
for call-site arity only). The old draw test became the climb-order pin.
Session log: THE DRAW SCREEN RETIRES (2026-08-29, same day, STAGING ONLY)
Oliver: "the draw ist doch jetzt eigentlich obsolet, oder?" — yes. The screen was built for the
18-rung ladder (his own 2026-08-15 ask), where four bands rolling out of a deep hat was a
ceremony; a league cup shuffles 2-3 faces the setup's league panel already shows face-up. Deleted:
the #bracket stage (DOM, CSS, buildBracket, its timers and routing). What survives: the shuffle
itself lives INSIDE the run (cupDraw, without replacement, the final settled — startMatch's draw
parameter stays for the suite's validation test, renamed accordingly), each round still announces
itself on the walk-on modal, and the cup's big setup button now says ENTER THE DRAW — all the
ritual the moment needs. The rules modal's copy went league-neutral ("UP YOUR LEAGUE'S DEAD
HIERARCHY — THE FINAL IS SET"; a 3-rung league plays three rounds, not four). The screen's
mechanics live in git history (like the lob) if the ceremony is ever asked back.
Session log: THE LEAGUES — the plaque moves mid-court, and the 94vw scrollbar (2026-08-29, same day, STAGING ONLY)
Live review with Oliver on the pane, three finds:
- The plaque left the foot — its solid panel sat exactly on the backdrop's blood pool, and LEVEL/LEAGUE were 8px footnotes. It floats CENTRED under the title now, on a translucent surface (rgba ink 0.48, radius 12, no border — the art shows through), with the wappen on top, the name, LEVEL · LEAGUE in Bangers 27 as the announcement it is, the bar, and the goal line (warm, wrapping). The foot is back to PLAY MATCH + two buttons and the blood pool breathes.
- The phantom horizontal scrollbar (Oliver spotted a sliver of h-scroll): TWO causes. The
goal line's
white-space: nowrapset the card's min-content past the viewport (the longest goal is ~380px) — it wraps now. And the ROOT cause predates the leagues:.card { width: min(370px, 94vw) }is CONTENT width beside 28px of padding, so the card overflowed every phone below 466px (0.94w + 28 > w) and #intro'soverflow-y: autocomputes overflow-x to auto. Nowmin(370px, 100vw - 28px)— desktop cap intact, phones exactly viewport-wide. - The stylesheet carried a full duplicate block (level strip + old plaque + bar tracks + career rules, twice) — leftovers of the mis-spliced ribbon edit that the .way dedupe missed. Removed; the dead #xpRow rules went with it.
Session log: THE CLIMB — the arcade gets its identity back (2026-08-29, same day, STAGING ONLY)
Oliver: "arcade verhält sich genau so [wie der cup].. die grenzen werden unscharf." Right — the phase-2 band clamp had reduced the arcade to the cup's own 3-4 bodies, loose. The two modes have sharp identities again:
- THE ARCADE IS THE CLIMB: it opens at the very BOTTOM of the ladder (opp 1) and runs the whole EARNED club, clamping at your league's top (the clamp, its felled counter and its "NOBODY STRONGER / THE CUP IS THE WAY UP" walk-on lesson all stay). The marathon's length grows with every promotion — league 2 climbs 8 bodies, league 5 all 18 — and the FELLED board means something again. Poster sub: "THE WHOLE CLUB, BOTTOM UP · BLOOD ONLY".
- The tempo hangs on the OPPONENT'S territory now (
config.leagueOfRung, read by leaguePace): the low ladder plays its own shipped tempo and the world pulls away at each band border — the climb makes the five leagues legible as five territories. Cup unchanged in effect (its pool IS your band); the training court now practises a body at the tempo you would really meet it at (a deliberate revision of phase 2's flat league-1 training world). Netplay exempt. - The climb picks its court freely (among the unlocked): the CUP is the league fixture with the fixed home venue, the climb is the exhibition. start()'s forcing, the setup's single-row venue face and the summary's LEAGUE suffix are all cup-only again.
- The climb's setup face (replaces the league panel there): THE CLIMB writ large, "THE CLUB FROM THE BOTTOM — N STAND · BEST RUN: M FELLED" (stats' own longest), no roster wall — the training picker already shows the club face-up.
- "RUNG" is gone from the game's copy (Oliver: too exotic) — the training tiles say "No. 7", the club roster number. It only ever appeared there; everywhere else the word lives in comments.
- Tests reworked: the climb walkthrough (opens at 1, clamps at 8 for league 2, felled honest) and the pace test (flat inside a band, steps at each border, netplay/gore exempt). Suite 124.
Session log: THE LEAGUES — the setup grows a league face (2026-08-29, same day, STAGING ONLY)
Two more from Oliver's screenshots:
- THE LEAGUE PANEL. The generic MATCH SETUP screen showed the player block (tour/kit/skin) on the league modes too — redundant since the identity moved to the card. On zombie/cup the block now steps aside (syncSetup toggles, "01 · YOUR LEAGUE") for the league writ large: the breathing badge, "LEAGUE N — NAME" in the league colour, a felled tally line ("2 OF 4 FELLED · THE CUP IS THE WAY UP" / "ALL 4 FELLED — TAKE THE CUP"), and the band's roster as head tiles — felled bodies in colour with their ALL-TIME count (stats' per-foe tallies, key 'L'+index), the rest grey; the band's top rung wears the red border, it is the final. Every other mode keeps the player block (main.buildLeaguePanel).
- The ribbon grew (13px type, taller band), per his call.
- The court section stops pretending on the league modes (his catch: "eine Liga hat einen festen Court — eigentlich sollte gar keine Courtauswahl da sein" — right; start() had forced the venue all along while the list still offered a pick). On zombie/cup the section reads "02 · HOME COURT · SET BY YOUR LEAGUE" and shows exactly one ringed row, the league's court; the click handler refuses there. One authoritative loop in syncSetup paints on/lock/display for both faces — the first cut had the league filter ABOVE the generic repaint, which promptly undid it.
Session log: THE LEAGUES — the polish round: the ribbon, the glow, the count-up (2026-08-29, same day, STAGING ONLY)
Oliver's retest ("schon viel besser") with five polish items, all built:
- Every chevron is UPRIGHT now. Bangers is drawn slanted, so
‹/›read as italic on every screen (his "an vielen Stellen"). All.chevbuttons and the plaque's arrow use Silkscreen's straight ASCII</>instead — the announcement face keeps its job, arrows stop leaning. - The plaque wears rounded corners (8px) and real padding (12/14 against 8/10).
- THE WAY-UP RIBBON: settled after four cuts as a HORIZONTAL band (Oliver's call) — red
field, thick white Silkscreen with a light shadow, running in from OVER the left border and
dissolving rightward through a real PIXEL FADE: an inline-SVG mask of 6px cells thresholded
against a Bayer matrix, four density steps — the dither the game's own art would use, never a
smooth gradient (and never a rotated pixel font, which was the diagonal version's sin: the one
anti-aliased thing in the menu; the diagonal also hid a text-WRAP bug that made the band a red
block). The mask eats band, bevel and shadow together. It shows on BOTH picker levels: the
top-level ZOMBIE TENNIS poster carries it too (
tagon the MENU row), so the click path to the promotion is marked from the first screen. - Poster captions grew: titles 12 → 15 bold, sub lines 9 → 10 and a brighter grey — "viel Text in sehr kleiner Schrift" was true of every banner.
- The card performs its entrance: earned badges and the current crest BREATHE a subtle gold
halo (
badgeGlowkeyframes, CSS-only — the parked menu loop is irrelevant to CSS animations; the dark slots stay dead, which is their point), both bars sweep from empty (0.7s eased transition, forced-reflow reset), and every number counts up fast — the XP line and all six ledger figures through onecountUpeaser (setTimeout, the bracket's own no-rAF rule; timers cancelled on rebuild so re-entry never races itself).
Session log: THE LEAGUES — the plaque round: the meta speaks (2026-08-29, same day, STAGING ONLY)
Oliver's first screenshot review, four items, all built. The theme: the meta layer existed but did not TALK to the player.
- No second bar (his question, settled in discussion): a career bar moves ~2% a session late and demotivates more than it informs. Total progress reads as the LEVEL NUMBER + LEAGUE instead; the card carries the long form — "LEVEL 12 OF 50" and a thin CAREER bar (whole-curve xp, honest about how far the cap is).
- THE PLAYER PLAQUE replaces the bare level strip on the splash: ONE button-styled panel — league wappen, name, "LEVEL X · LEAGUE Y" over the bar, a chevron that says it opens (the bare strip's tap was undiscoverable), and the standing GOAL LINE: "WIN THE GRAVEYARD SHIFT CUP TO ADVANCE" / "…BECOME CHAMPION" / "CHAMPION OF THE DEAD CLUB — DEFEND THE NAME". The badge COLLECTION deliberately stays on the card (splash real estate: the boxed-bar lesson) — now one DISCOVERABLE tap away.
- "How do I advance?" is answered in three standing places (the promotion ceremony taught it
exactly once, and only AFTER the first promotion — too late): (1) the plaque's goal line;
(2) the arcade's own "what now?" moment — when the band's top body walks BACK on (
d.again, set by the clamp), its walk-on modal carries "NOBODY STRONGER IS LEFT / THE CUP IS THE WAY UP" (pinned by the band-clamp test); (3) the tournament poster wears a corner tag THE WAY UP (tagon the MODES row, rendered by banner()) and its blurb says "YOUR LEAGUE'S ROUNDS · WIN IT TO RISE". - The promotion ceremony's court tile is centred (
margin auto+align-self— the .mid column stretches block children, so the 74%-wide img sat left), and the death screen's XP line moved to +24/+36/+50 — the pulsing ★NEW BEST★ is B(20) drawn top-baseline at +2 and overlapped the +18 line whenever both showed.
Session log: THE LEAGUES, PHASE 4 — the duel learns, tin tiers, the level is you (2026-08-29, STAGING ONLY)
Phase 4, the last of the built plan. STAGING ONLY — the whole feature waits on Oliver's own phone test before merging to main.
- THE DUEL LEARNS. The diagnosis held: in a duel the racket zombie played with every ladder
term inert (chase collapsed to
racketSpeed, read to flataiRead, flub a flat 0.2, quality capped 0.55) and only hp stiffened — atduelFirst 2 / duelEvery 3that made it a weapon DISPENSER, the same inflation class the barrel economy was cured of.gameplay.duelSkill()(0 → 1 overTUNE.duelRampGames24, onG.runGames— gore's own clock, the league never rekeys the director) now ramps its legs (duelSpeedMax+35%), read (duelReadMax0.93), whiff (duelFlubCut: 0.2 → 0.08) and return quality (duelQBonus: 0.55 → 0.75). Game 0 is exactly the shipped duel (calibration rule). Losing already costs plenty (opponent game + runGames++ + the 5-body surge + no tin/heal) — now it can actually happen late. - THE TIN TIERS (settled: no starting loadout).
tinLadder()(pure, tested): a barrel weapon joins the duel pot when the RUN unlocks it (as ever) OR when the LEAGUE says so —config.TIN_LEAGUElasers at 3, rockets at 4 — and league 5 duels pay DOUBLE tins (TIN_DOUBLE_LEAGUE, the card says ×2N). "Weapons are only ever won" stays literal. - THE LEVEL IS YOU:
profile.bonus()— level 1 = all factors 1 (shipped), scaling linearly to the cap: melee cooldown FLOOR ×0.75 (the ceiling untouched), parry reach/arm ×1.25, hurt grace ×1.35. Read at the dial sites (meleeCooldown, keyPressed's parry arm, zombies' parry window + face-offset, hurtPlayer). Applies everywhere the dials do — gore included, which is the cross-mode incentive the plan asked for. - THE BOARD WEARS THE BADGE: score rows carry
lg(server clean() bounds it 1-5, default 1; context, never a sort key),stats.submittakes it as a parameter (profile cannot be imported there — cycle), main passesprof.lg, and drawRanks renders the little shield before the name. Old rows carry none and show none — no migration. - Tests 121 → 124 (duel ramp shape + its caps stay fallible, tin tiers incl. the double tin and
the run-gate fallback, bonus() bounds + the grace landing on
G.hurtT). Seeds fuzzed. Server round-tripped live (lg: 4stored, served, and drawn as the navy shield on the board).
Session log: THE LEAGUES, PHASE 3 — the character card, badges, the skill trail (2026-08-29, STAGING ONLY)
Phase 3. STAGING ONLY, like 1 and 2.
- THE CHARACTER CARD (
#card, main.buildCard): the splash's LEVEL STRIP is the door (tap it — it wearscursor: pointerand nothing else changed on the splash). Name as the screen title, the live sprite (drawLook, third canvas on refreshLook), the current league's crest + name in the league colour, LEVEL in Bangers over the gradient bar with "N XP · M TO LEVEL X", an HONOURS row, and THE LEDGER (two .figs rows off stats.read per mode: kills / multi-kills / deaths, best run / most felled / runs). EDIT PLAYER borrows the identity screen and returns to the card (playerReturn). Deviation from the plan: no death-screen entry — the death screen already carries the XP moment inline, and its tap is the restart. - The honours row is a COLLECTION: all five league shields plus the champion's laurel always
visible, earned bright, unearned dark (
filter: grayscale brightness) — the empty slots are the incentive. A shield is earned by FINISHING its league (being past it); league five's lights with the champion crown, since there is no league six to be past it. - Badges rolled via OpenRouter (one sheet on #FF00FF, cut by connected components + chroma
key, 4x NEAREST downscale):
badge_l1..l5.png(terracotta / slate / grass / navy-ice / marble-gold shields, each with the crossed-rackets motif and a chip of decay) +badge_champ.png(gold laurel + cup). DOM<img>only — not in SPR, nothing draws them on canvas yet. - THE SKILL TRAIL:
LEAGUESrows carrycolor(each riding its court's palette), and the player's OWN sprint dust wears it —dustPuffgrew an optional colour, the mover's emit passesskillDust(). League 1 keeps the shipped court dust: the tint itself is a progression reward. Read off the profile (notG.league), so the gore mode shows your colour too. The aura stays made of MOTION, never paint — the full-body glow idea was dropped against the painted-layer veto. - Pane-verified: card at league 5 (four bright shields, dark L5 + laurel, ledger numbers, edit round trip). Suite unchanged at 121 — the card is DOM, the dust is a colour.
Session log: THE LEAGUES, PHASE 2 — bands, pace tiers, courts, the promotion (2026-08-29, STAGING ONLY)
Phase 2 of the plan. STAGING ONLY, like phase 1.
LEAGUESin config.js — five bands tiling the 18-rung ladder exactly (1-4 / 5-8 / 9-12 / 13-15 / 16-18, the club's own hierarchy), each with its home court: clay → hard → grass → gloom → members (new).leagueOf(lg), and the ladder-shape test now pins the tiling.CUP_ROUNDSis GONE; the cup carries its own rounds (cup.rounds, stamped by startMatch fromcupRoundsFor(G.league)). A league band is 3-4 rungs, so the draw is WITHOUT REPLACEMENT: non-final rounds carry the whole sub-band,cup.played(and buildBracket'susedset) stop a body being dealt twice, the final is always the band's top rung — "only the finale is settled" survives the narrow bands. 4-rung leagues play four rounds, 3-rung leagues three.G.league(state, PRISTINE 1) is stamped by startMatch — netplay and training stay league-1 worlds.gameplay.leaguePace()=1 + (league-1) * TUNE.leaguePace(0.06) multiplies ball pace on BOTH rackets (doHit, both serve paths) andrzChaseSpeed, so the world speeds up as one piece and the reach tests keep believing the legs. League 1 IS the shipped game (factor 1.0 — the calibration rule); league 5 plays ~24% faster. Everything non-match is factor 1: gore keeps its own director.- The arcade opens at its band's bottom rung and CLAMPS at its top — past it the league has
nobody stronger and the top body walks back on; the cup is the only way up.
d.felledcounts bodies now (opp is a band position,opp - 1stopped meaning anything — killPlayer, pagehide and the scoreboard slot all read felled). - Courts lock to the leagues: a court unlocks for EVERY mode when you reach its league
(settled reward formula). Rows keep their tile visible but answer no tap and say the price
("LOCKED — REACH THE OFFICIALS"); syncSetup repaints locks on entry; a remembered-but-locked
pick falls back to the league court. League matches force the league court in
start(); the footer says so plus "LEAGUE N". The training picker only offers rungs up to your band's top. - THE MEMBERS' GROUND (Oliver's pick): new COURTS row — brightly decayed prestige, dead
grey-green lawn (fastest bounce in the table, finishing the pace ladder), pale marble, ivy,
faded gold; borrows clay's crowd/ads with
dim0.15. Tile rolled via the famroll OpenRouter route (style ref = court_grass tile, 4x NEAREST downscale + quantise), SPRV 148 → 149. - THE PROMOTION CEREMONY: cupWon (non-train, cup of your CURRENT league) moves
prof.lg, parks a ceremony in the profile module (consumed-on-read, survives restartRun), and maybeRestart plays it before the name prompt: PROMOTED. / league name / the new court's tile / "OPEN IN EVERY MODE" / WALK ON. League 5 crowns instead (prof.ch, "CHAMPION."). The cup summary's big line is league-aware now: 'CHAMPION OF THE DEAD CLUB.' belongs to league 5 only, lower cups say 'THE CUP IS YOURS.' - Fix found live: the scoreboard's cup slot indexed
cup.rounds[cup.round]one past the end on the trophy frame (cup.round is incremented before cupWon is reached) — clamped. - XP bar wears a gradient (Oliver, mid-session): green → acid → gold, splash strip and the canvas bar on the death screen.
- Tests 118 → 121 (band tiling + per-league cup shape, promotion + ceremony parked, champion crown, arcade band clamp + felled, pace-tier scaling and its exemptions; the tournament walkthrough now also asserts no body is dealt twice and the promotion fires; freshSplatter resets the profile league so a won cup cannot leak league 2 into later tests). Seeds fuzzed. Pane-verified: locks, forced court + league footer, league-2 draw, full cup → trophy (+1512 XP · LEVEL 6 → 8) → PROMOTED ceremony → league-5 arcade opening vs THE BRUTE on the members court.
Session log: THE LEAGUES, PHASE 1 — profile, xp, the identity screen (2026-08-29, STAGING ONLY)
Phase 1 of the plan below, built exactly to it. STAGING ONLY for now (Oliver: he tests himself before the wider group) — do not merge to main until his verdict.
src/profile.jsis the profile:{xp, lg, ch, setup}in localStoragesplatterProfile, mirrored to the server (/api/profile, keyed by stats' existing client id). Name/tour/kit/skin deliberately KEEP their existing homes and are only mirrored outbound, so nothing has two owners. The mirror never goes backward on xp (server-side guard) andpull()adopts it only when ahead — a wiped phone gets its identity AND its number back.- XP hooks are deliberately few:
advancePoint(the one place every mode's point converges — points and games cannot be missed or double-counted), thekillZombiefunnel, duel win, cup round, cup won.award()carries the no-stakes guard ITSELF (drill, bench, testMode, training court), so a call site cannot forget it. Curve:80·(l−1)^1.8, cap 50 — first levels inside the first run, late ones sessions apart. All dials inprofile.XP. - UI: the splash carries a LEVEL strip (#xpRow, filled by refreshSplash); the death/summary
screen shows
+N XP · LEVEL a → bplus a progress bar (render.js, beside the best-run lines). A mid-run level-up announces as a CARD (popup policy: never a modal on a live court). - THE IDENTITY SCREEN (
#player, firstPLAY MATCHtap only): name prefilled with a ZOMBIE NAME (profile.zombieName(), two-part roll, always ≤12 chars and board-legal), a die button rolls another; tour/kit/skin pickers write the SAME picked* state and localStorage keys the setup screen reads.syncSetup()(called bygo('setup')) repaints the setup rows from picked* — without it the rows only tracked their own taps and showed the boot-time selection after an identity-screen change (caught live in the pane). - Server:
/api/profileGET/POST beside the scores API, same stance (whitelist re-derive, bounded, atomic write, 5000 profiles cap, oldest-touched evicted).profiles.jsonbesidescores.jsonunder/data— the volume that already exists. - Drive-by fix:
pagehidenow returns onG.drill— drill kills leak into the stats run bag (tally sits above the drill hook in killZombie) and a tab close mid-drill banked them as a gore run. Death was already guarded; the tab-close path was not. - Tests: +4 (curve shape, the award funnel + every mute, run-delta reset, name legality), suite
118, seeds fuzzed. Pane-verified end to end (identity flow, POST 200, death screen with +16 XP
and the bar; NOTE this pane session has rAF dead again —
T.step(1/60)loops drove the clock).
THE LEAGUES — the meta-progression plan (2026-08-29, AGREED; PHASES 1-4 BUILT ON STAGING — see the four logs above; phase 5 deliberately parked)
Worked out with Oliver in discussion, decisions settled below. The goal in his words: an incentive to keep opening the game beyond beating the next opponent — levelling through play, with a clearly FELT improvement in gameplay. This is the plan of record for the feature; build it phase by phase and record verdicts here like any other session.
The two axes, and the one rule that protects the shipped game
- The LEAGUE determines the world: pace tier (ball pace + opponent speed), opponent band, court.
- The LEVEL determines you: small honest buffs through existing TUNE dials — serve pace cap,
hard-serve scatter, parry window, melee cooldown floor,
hurtGrace. Never new gestures, never gesture removal. - THE SHIPPED GAME IS LEAGUE 1 (settled explicitly): today's tuning is the floor, progression extends UPWARD. No deliberately-degraded start, and nothing that exists today gets locked behind a level — carving the current game into locked pieces is a silent regression of a tuned build. League 1's opponents are tuned a notch ahead of a fresh player; the level grinds you into range.
- The tournament gate is a skill check, not a timer: winning the league's cup promotes you. The level makes the cup EASIER, never POSSIBLE — a genuinely good player may climb early.
Decisions, all settled with Oliver 2026-08-29
- Leagues are BANDS of the existing 18-rung LADDER — no new rosters. The league cup is the existing cup machinery drawing from the league's band (CUP_ROUNDS already draws from bands). Five leagues; THE CHAMPION is the final of league 5. Exact band edges are Phase 2 tuning.
- XP comes from ACTIVE PLAY EVENTS (points played, kills, games — practically playtime, but idle-proof), never wall-clock. Losing pays too — a hard league must be a grind, not a wall. The training court pays nothing ("NOTHING AT STAKE" is its settled motto; otherwise it is the AFK farm). Level curve: fast early (the hook), slow late (Oliver: levelling must not be easy). Iron rule: every session ends with a visible delta — bar moved, badge, milestone.
- Courts ride the leagues, and the bounce table already agrees: 1 TERRE ROUGE (slow clay), 2 THE SLAB (true hard), 3 CENTRE COURT (fast grass), 4 THE NIGHT COURT, 5 THE MEMBERS' GROUND — new court, the Dead Club's decayed place of honour: dead grey-green lawn, cracked lines, ivy over marble, faded gold on the boards. Brightly decayed, distinct from gloom's dark. Built like gloom was: a COURTS row + tile + palettes, borrowing a stands sheet with dim/grading before we roll a fourth crowd grade. League matches play the league's court; free court pick stays in classic/netplay/training, and finishing a league unlocks its court for all modes (settled — the unlock is the reward formula).
- Gore coupling is TIN-TIER, no starting loadout (settled): the level/league determines the
POOL the duel's tin pays from (placeholder ladder: lasers join at league 3, rockets 4, double
tins 5 — numbers are Phase 4 tuning). "Weapons are only ever won" stays literally intact and
the world board is not devalued by starting inventory. The duel gets fixed FIRST: at
duelFirst 2 / duelEvery 3with hp-only stiffening (racketHpPerGame0.12) it is a weapon dispenser, not a gate — the same inflation class as the old barrel income. The racket zombie gets the ladder's tennis brain (pace, chase, flub, THE READ scaling), so a duel can be LOST and a won tin is worth something. While in there: establish what losing a duel actually costs; if the answer is "nothing but the tin", that is half the diagnosis. Gore's own difficulty director stays keyed onG.runGames— the league buffs YOU, it does not rekey the director. Defensive level buffs (melee floor, parry, hurtGrace) apply in gore; board rows carry the league badge — context instead of score normalization, and an upset is visible. - No full-body aura — that is the painted-layer pattern the player-blood veto already killed. The skill colour lives in: a TINT on the existing sprint trail/dust register, the character card, and the walk-on/scoreboard presentation ("CHALLENGER · LIGA 3").
- The character card: big sprite, level bar, league badge, trophy icons (rolled through the sprite pipeline), totals from stats.js (the data already exists). Reachable from the menu AND the death screen — the death screen is where meta-progress absorbs permadeath ("run over, but +340 XP, level 7 → 8"). First-class typography per the polish rule.
- The profile is backed up SERVER-SIDE before the grind clock counts. Hours of investment on localStorage alone dies with cleared browser data. Client ID, name ownership and the /data volume all exist; a profile blob beside scores.json is the small addition.
- Deferred, on purpose: skill matchmaking (a queue at our playerbase is empty, and an empty queue is worse than none — show the league badge in netplay instead; revisit with players), and a daily bonus (first-win-of-the-day; smells of mobile dark patterns — Oliver's call later).
The phases
- Phase 1 — profile + XP + bar. One profile (localStorage + server backup), events pay in, level bar in the menu and on the death screen. NO gameplay effect yet: the clock must visibly tick first.
- Phase 2 — leagues. Ladder bands, pace tiers, court per league, league cup as promotion gate, court unlock. The roster fine-tuning lives here; phone-verdict heavy.
- Phase 3 — character card + badges + trail tint. The presentation.
- Phase 4 — gore coupling. Duel fix first, then tin tiers, defensive buffs, board badge.
- Phase 5 — later. Matchmaking, daily incentive (both deliberately parked, see 8).
Open tuning questions for the build (not decisions): what a pace tier IS mechanically (one global pace multiplier vs per-dial), the XP amounts and curve numbers, the five band edges over 18 rungs, the tin-tier ladder, and the duel-loss cost.
Session log: THE READ, the walk-on modal, the training court, movement reads (2026-08-28)
Four items from one phone report. PHONE VERDICT PENDING on 2-4; item 1 got its first verdict the
same day — "die Gegner sind jetzt etwas zuu einfach" — and was turned down a notch: readHabit
3 -> 4, surpRead 0.85 -> 0.6, surpSlow 0.45 -> 0.3, surpFlub 0.35 -> 0.22. A surprise is an
opening the player still has to play, not a point handed over. The whiff-window test pin moved
0.25 -> 0.15 with it.
1. THE READ — the twist for the post-COACH wall
Oliver: past the HEAD COACH "the opponents are very fast and barely make mistakes any more — we need a twist, maybe a kind of cognitive fatigue when you make the same move ten times and then surprise them." Built as HABITUATION, his own idea with the numbers made playable:
- doHit buckets every aimed player rally shot into three lanes (target x at ±1.1) and keeps a
same-lane run on
G.match.laneRun.TUNE.readHabit(3) same-lane shots = the habit is learned. - One more of the same lane is
expected: the opponent'saiReadclimbs towardTUNE.readAnticipate(0.97) — spam feeds the machine, so the late ladder punishes one-note play. - Breaking the habit is
surprised, for that one flight: the read collapses (surpRead), the chase speed drops (surpSlow, applied insiderzChaseSpeedso the intercept planner and the early-fall test believe it too), andsurpFlublands on top of a whiff rate the champion had reduced to almost nothing. A "WRONG-FOOTED." flash announces it (rungs past ~20% only). - Everything scales with the rung (
learnK, (opp−1)/(LADDER−1)): the shambler never learns, the champion — "HE RETURNS EVERYTHING" — is exactly the one you beat by setting a habit and breaking it on the big point. Solo match only (netplay's opponent is a human), rally lanes only (serves keep their own game — open: should serve placement feed the same book?). The one-ball states are cleared at the racket and at every point end (matchReady); a fresh walk-on resets the book. Tests pin the state machine (real swings throughkeyPressed) and the dial relationships (a wrong-footed champion runs slower than the head coach at full stride).
2. THE WALK-ON IS A MODAL (tournament + arcade + training)
Oliver: "die Intros der einzelnen Spieler bitte als richtiges Modal, das man wegtappen muss —
man kann dann wirklich lesen was da steht, und schnell wegklicken wenn man es schon 100x gelesen
hat." matchOpponent now calls showModal (head icon z_<fam>head, the punchline, the cup's
round line) instead of the timed 4 s card. The walk-on court is guaranteed-calm (the mop beat has
just ended), so the popup policy allows it; the freeze holds the 1.6 s intro bar until the tap.
The mode-rules popup fires BEFORE matchOpponent in startMatch so it lands first and the
opponent queues behind it. Netplay untouched (no walk-on there at all). The test harness's
passMopBeat now taps the walk-on away like a player would.
3. THE TRAINING COURT (MODES id train, third kid of ZOMBIE TENNIS)
Oliver: "ein Trainingsbereich — Auswahl des Gegners, ein Spiel, tap to replay, bis man keine
Lust mehr hat." One flag (d.train, set by startMatch(false, null, rung)) on the whole match
machinery:
- SERVE UP routes to a picker screen (
#train, built from the roster like the boss picker, the samez_*headcrops the draw uses); tapping a rung IS the start — tap-advances, like every other screen.mode_train.pngrolled in the banner style (golden-hour practice court, ball basket, cones, no text) via the famroll OpenRouter route. - The rung is PARKED:
d.oppnever advances, a felled body walks back on as itself, and the walk-on modal (item 2) is the "tap to replay" gate for free. Between reps the blood resets silently (no WOUNDS TREATED ceremony — a popup per rep is the tap-tax the mode avoids). - NOTHING BANKS: no
G.runGames, noendRun(pagehide guarded), and bleeding out is a modal ("IT BLED YOU OUT") plus a fresh pool, never the death screen. The quit chip is the exit. - The board's progress slot reads
TRAINING · <SHORT>; the setup footer says "NOTHING AT STAKE".
4. MOVEMENT READS — dust and speed ghosts (feel-tunable, verdict pending)
Oliver: "die Bewegung der Spieler optisch anreichern — motion blur / particles / shader, hier bin ich auf deinen Input angewiesen." Two cheap effects in the existing registers, no shaders:
- Sprint dust: feet crossing
TUNE.trailMin(3.6 m/s — above every shamble) kick one per-courtdustPuffperrunDustEverymetres. racketAI's chase and the player's mover (auto-run, horde steer) both emit; the mover measures its travel AFTER the clamps so a body pinned at a bound reads as standing. - Speed ghosts:
drawZombie/drawChardraw two alpha ghosts sampled back along the body's own velocity (trailKs × 1 and 2) — the modal masthead's speed-streak trick applied to bodies. Only genuine sprints qualify; never during a named frame (showFrame— a collapsing corpse must not smear on stale velocity). racketAI now recordsvzbesidevx; the player'sP.vx/vzare written by the mover and zeroed outside it. Both are panel-tunable (trailMin0 kills both). Not tried, on the shelf if these fail the phone: a one-frame lean/skid sprite on hard direction changes (needs drawn frames, per the draw-frames rule), and court-colored skid streaks on the ground behind a hard stop. First verdict, same day ("dezenter, schneller ausfaden — teilweise bleiben die doppelten ghosts länger sichtbar"): the lingering was a real bug — racketAI's!incomingreturn kept the last chase'svx/vzon the body, so the ghosts trailed a zombie that had already stopped; the velocity now dies with the chase (and a test pins it). The look was then halved with it: ghost alphas 0.22/0.10 -> 0.11/0.05,trailK0.05 -> 0.035. The trail must read as motion, never as residue or a second body.
Session log: THE LOB (BUILT, THEN CUT) AND TWO THINGS THE MULTIPLAYER TEST FOUND (2026-08-22, same day)
THE LOB WAS BUILT AND REMOVED THE SAME DAY — and the reason is the useful part
Oliver: "when you're close to the net in a duel with another player there should be a gesture allowing to make a ball flying high from below (lupfen)... this likely needs to be discussed." Three candidates were put to him — (a) slow drag vs fast flick, (b) a down-then-up hook, (c) let CONTEXT arm it: the forward flick keeps its one meaning ("put it deep behind him") and the game picks the trajectory that does that, which with a man at the net is over his head. He picked (c): "i think this is the easiest. no additional gesture to learn."
It was built, measured and shipped. It worked exactly as specified — armed at TUNE.lobArm 7 m
(the distance at which a lob peaking at lobApex still passes over CONTACT.maxY), pace derived
from the height so the arc was the same from the baseline and from mid-court, target deeper than
SHOT_DEPTH.deep, and the word LOB popped at the contact to say which shot had just been asked for.
His verdict, within the hour: "all good but the lob. please remove it — this is totally
unpredictable." It is reverted in full: oppAtNet, farBody, TUNE.lobArm, TUNE.lobApex,
SHOT_DEPTH.lob, the branch in doHit and its two tests are gone, and the forward flick is the
smash it always was.
THE LESSON, AND IT IS A RULE NOW: a gesture whose MEANING depends on where the opponent is standing is unpredictable, however well the shot itself is tuned. This is the same failure the input layer already names in its own words — "No timing window and no invisible mode: the direction you moved is the thing you asked for" (the slingshot's sidestep-vs-pull comment). The written comparison put that objection against option (a) and let (c) past on the argument that context can never mis-fire. It can: the player does not track where the far body is standing while a ball is coming at him, so the same thumb movement produced two different shots for reasons he was not watching. The word at the contact told him WHICH shot he got, but a label after the fact does not make a gesture predictable — it only explains the surprise.
If the lob is ever asked for again, it needs its own gesture (option (b), the down-then-up hook,
is the only candidate left that cannot be confused with an existing one) — and the mechanics above
are all still correct and are recorded in the git history at 4f4534e, so the shot itself is a
half-hour's work. The question was never the ballistics.
"THE OPPONENT SEES THE BALL REALLY FAST — THE PLAYER BARELY SEES THE SPEED INCREASE"
Measured first, because the report has two possible causes and only one of them is a bug.
The ball is the same ball on both phones — netSendShot copies vx/vy/vz/g verbatim and the
receiver mirrors them, no re-simulation. What differs is the CAMERA: it sits behind your baseline,
so your own ball's screen speed decays into the distance while the same shot expands toward the man
receiving it. That asymmetry cannot be fixed by changing pace.
The pace ladder, measured: serve 49.2 plain / 50.8-52.4 with the hard flick (the +9 m/s
serveHard asks for 56-58, and serveArc's net-clearance loop takes 10% of it back in one cut);
rally 19.2 + 14.4×timing, ×1.42 for a smash at flush timing — so the smash is a real 42% when
you time it and nothing when you do not, and the hard SERVE is only ever ~3-6%.
Nothing about the pace was changed. Three feedback channels were added instead, all on the striker's side, none of them touching a ball's physics:
- The ghost streak now reads PACE. It was three ghosts at every speed, so it said fast/not-fast and nothing else; it is 3 to 8 now, sampled every frame, so a quick ball is both longer and more spread out. A lob stays clean.
- The crack is pitched by pace (
strikeRate, 0.93 to 1.09 — past a tenth the sample stops sounding like the same racket). - A fast ball kicks the camera (
strikeKick, up to ~2.2 px for 0.12 s, YOUR racket only, and never over a shake already running).
Still open, and it is Oliver's call. If he wants the hard serve to actually BE harder, the knob
is serveArc's loft margin: it charges 0.06 + descent×0.04 (about 0.46 m of extra net clearance
on a fast serve) against a sampling error the sim does not really make any more — the net test in
update() already interpolates the crossing height, and semi-implicit Euler's real lag at the tape
measures ~2-17 cm. Cutting that margin gives the hard serve its full +9 back (58 vs 49, a difference
you could see). Not done unasked: a serve into the tape is worse than a slow one, and that margin
exists because balls were clipping it.
THE DEATH MATCH: THE BURST IS THE DEATH, AND ONLY BLOOD IS
Oliver: "each player has 8 blood points but the player explodes for the winner already at 0:40 —
the winner sees the zombie explode but the player on the other side is not dead yet. this needs to
be aligned." Two clocks were running side by side: a game is four points, a life is eight, and
matchGameWon burst the friend's body on the game. Both were true on their own phone and they
disagreed with each other.
- A won game is only a game now (netplay branch of
matchGameWon): the flash, the serve change and the heal it pays, then the next point. No burst, no walk-on, no mop beat — there is no roster body to walk back on in a death match anyway. - The burst moved to the death, in
netMatchWon, which fires on thedeadmessage the dying phone sends when its own blood runs out. Both sides are then agreeing about the same event. - The summary waits
TUNE.netWinBurst(1.4 s) for the gore to fly.update()freezes the whole world onG.done— no particles — so setting it on the same frame as the burst hangs a still frame of an explosion behind the trophy screen. Ticked intickMatchFall, like every other beat in beats.js that has to outlive the frame that started it. - The friend's bleeding tracks THEIR blood.
bleedOread points-won-this-game, which resets every game and ignores the heal — a friend two points from death looked unhurt. Their hp now rides the 10 Hzposstream (one number; a dropped one corrects itself 100 ms later) and the flow reads it. The old count survives as the fallback for a build that sends none.
Session log: THE SERVICE RULES ARRIVE IN THE OTHER MODES (2026-08-22)
Three notes from Oliver, two of them fixed here; the third (the lob) is under discussion in OPEN.
- A RECEIVER STANDS BEHIND HIS BASELINE. "when the opponent serves the player does not stand at
the baseline — but that's how it should be."
setupPoint's receiving marks were z 22.5 near and 1.2 far — over a metre INSIDE the court on both sides, which is where you stand to attack a second serve, not where you wait for a first one, and next to a server drawn behind his own line it read as a man who had wandered in. They now mirror the server's depth (24.2 / -0.43). It is one line, so every mode that serves through the tennis branch got it: classic, gore's opener, the match, and netplay. The duel is unaffected by design — it takes setupPoint's splatter early-out and is always your serve, from wherever you are standing. - A MISSED SERVE BUYS A SECOND ONE. "when serving results in an out there should be a second
service... the umpire should announce 2nd service and the player must get another chance." True
in tennis mode since forever, and absent from every other mode: the duel, the match and the
death match had no service rule at all, so the first serve was judged as an ordinary ground stroke
and a serve long or into the net handed the point away — in the match that is a point AND blood, on
the very first ball of it. The out branch of
onBounce's interlude block now routes a serve tofault()instead ofresolveInterlude, so the second serve, the HUD's SECOND SERVE line, the umpire and the double fault all come for free. Three things it dragged out with it:fault()'s double-fault branch calledpointTo— TENNIS scoring — which in an interlude would bank a number and skip the mode that owns the court (blood, the fall, the ladder). It routes throughresolveInterludethere now. Nothing had ever reached that line from a duel or a match, because those modes could not fault.startDuelnever resetG.serveNum. Harmless while a duel could not fault; the moment it can, a fault in one duel arms second-serve in the NEXT one — the exact death loopmatchReadyalready carries a note about, one mode over.- The match puts the body back on its receiving mark for the second serve (
placeReceiver, split out ofmatchReadybecause that call resets the serve number a fault has just spent). Not in netplay: the called ball is left rolling there, andmatchReadywould freeze it.
- THE BOX IS STILL NOT ENFORCED IN THE MATCH, deliberately. Classic checks the service box; the
match's line call only ever checked the court, and a deep-but-in serve stays legal there. That is
the concession the mode was tuned around (see the "classic tennis gives double fault very often"
note in
keyPressed) and this change is strictly more forgiving than what shipped. Enforcing it is a one-line change if Oliver wants real service boxes in zombie tennis — ask before doing it. - One new recording:
second_service.m4a(say -v Daniel -r 168->afconvert, the existing pipeline). It plays as a SEQUENCE with the fault call —voiceSeq(['fault', 'second_service'])— becausevoice()drops any line that would start while the umpire is still talking, so a secondvoice()on the next line would have been silently swallowed. "Fault. Second service."
ZOMBIE TENNIS — the mode, end to end (folded from HANDOFF-MATCH.md, 2026-08-17)
HANDOFF-MATCH.md was a temporary file written when a session ran out of context mid-slice. The
slice is finished; this is what survives it. The reasoning behind the numbers is in the drill/match
session logs (passes 13-23) further down — this section is the contract, not the diary.
What it is. Real tennis against the racket zombie. One game per opponent, tap-and-timing
controls, the deuce/ad serve positions, and the score IS the body: each point you win takes a
quarter of its pool and walks it through its wound stages, and the game-winning point fells it so a
fresh opponent shambles on. Its points draw your blood (matchPlayerHp 8, matchHeal 2 per game
won). The measure is OPPONENTS FELLED — in play (OPPONENT n · m FELLED under the board) and on
the death screen. Entry: the setup screen's three modes (config.MODES), or the back room's MATCH
TEST, which still works and is the fastest way in for a test.
It runs on a PERMANENT interlude. G.interlude is what makes serving tennis-like, hides the rack
and wakes racketAI; the only structural change is resolveInterlude feeding matchPoint instead
of ending anything. So G.mode is 'splatter' here, never 'tennis' — every mode gate written
as G.mode === 'tennis' silently excludes the match, and that has been the shape of four separate
bugs (the umpire, the boy, the serve, the flick lesson). rallyFeel() is the honest test for "a
real rally mode": classic AND the match, and NOT gore's one-game prologue.
SCORING IS BLOOD-ONLY (Oliver's pick of three). The run is one long bleed across opponents; losing a game costs nothing but the blood already spent, and heals are how you last. Model B — tennis-true, a lost game ends the run — was rejected for now and is still worth one playtest; it might be the mode's real personality. Context worth keeping: Oliver believed a lost match killed him ("i really die losing one match"). It did not — he was bleeding out during deuce wars. That misreading was itself the bug: blood got a pip readout beside the board because the vignette says "worse" and never says "how many".
The ladder escalates in racketAI, not in hp — the pool is flat. Four dials, each climbing per
rung: speed (matchSpeedBase/Per), whiff (matchFlub/FlubKeep), width (matchWideBase/
WidePer), pace (matchOppPace/PacePer). Player side: matchAssist, matchReach.
THE CARD — ten bodies, one list read twice. config.OPPONENTS is the narrative AND the
difficulty curve: the further you get, the further up the club's hierarchy the corpse across the net
used to be. SHAMBLER, BALL BOY, GROUNDSKEEPER, STEWARD, RUNNER, LINE JUDGE, CLUB PRO, THE BULK,
DIRECTOR, CHAMPION — each with a name, a short for the board, a line, a sprite family and a
personality that is DIALS (pace, width, flub, speed scalings on the numbers racketAI already
reads). The ramp is asserted, not assumed: a test walks all ten and requires the whiff rate to
fall every rung, the names to fit the board's 8-char slot, and speed never to step meaningfully
backwards. Speed may ZIGZAG by character (5 is the RUNNER, 6 paints lines instead of sprinting); the
whiff rate is the dial the ramp actually rides on.
THE WOUNDS PLAY (2026-08-17). config.matchWound reads the same racketStage the SKINS are
picked with and returns {reach, flub}, which racketAI spends on its contact radius and its whiff
rate: a one-armed opponent covers less, a headless one misses more. Deliberately SMALL — 6% reach
and 17.5% whiff PER STAGE (Oliver: "not too pronounced") — texture under the ladder, never a second
difficulty curve, and a test pins that ceiling. Unhurt returns exactly {1, 1} and the whole thing
is gated on G.match, which is what keeps the gore duel's racket zombie untouched. Note the
arithmetic: a quarter off a full pool is 0.75, so point one crosses no stage — the dials move on
points two and three, in step with the art.
THE FALL. Point won → the body COLLAPSES → drags itself up minus the limb (beats.tickMatchFall,
on its OWN clock beside the horde's warning — a beat that moves the phase under a latch strands it,
the mgT lock again). The 'over' beat it also sets is deliberately longer than the fall, so it cannot
expire into setupPoint() mid-collapse and hand you a serve over a body on the ground. The
contract: the three collapse frames wear the stage it STILL WAS, the rise wears the stage it has
BECOME, and it only plays when the stage actually moved.
THE FLICK IS THE MODE'S ONE ACT OF SKILL, and it used to lose the point. Oliver: "8/10 times the
ball lands out". It was arithmetic. The singles line is 4.115 m; an aimed shot targeted a flat width
and THEN added err() on top — up to ±aimError at quality 0 — so a flicked return landed as wide
as 5.7 m, while an UNAIMED return (target 0 + the same scatter) was always in. Aiming was the thing
that lost the point. TUNE.flickReach scales the aim width by the same quality the scatter shrinks
with, so the two can never sum past the line, and a test asserts that sum across the whole quality
range rather than sampling rallies. Not named aimReach — that key already exists further down
TUNE (the slingshot's aim ray) and the duplicate silently won, which a test caught. The placement
ring carries q and reads as the flick's receipt: tight and hot when your timing earned the full
width, wide and dim when you barely got a racket on it.
...and it is TAUGHT (2026-08-17), because the ring is a receipt, not a lesson — you only get one
if you already knew to swipe. The ghost hand mimes it on the apron in the SERVE STANCE, in the very
demo the slingshot lesson plays, armed by a run of returns that left with no aim on them
(G.flatStreak / flatCount, TUNE.flickHintAfter) and disarmed by the first aimed one. A
gesture is read in a pause, not in a point (Oliver: "it is displayed mid-play.. please rather show
it in one of the pauses") — the first cut rode the incoming ball, at the moment the player has the
least attention to spare. Touch only, rally modes only, and the tap lesson wins if both qualify:
teaching placement to someone who cannot reach the ball is the wrong lesson.
...and its LENGTH is its strength (2026-08-18). Oliver: "the flick to aim sometimes is applied
very strong, sometimes it barely does anything. this leads to a lack of agency, the player can't
really tell if there was skill or luck." He was right and it was the gesture reader, not the feel:
the rally flick was quantised to THREE LANES (ax, a swipe past 0.38 of its own length sideways),
so two neighbouring gestures differed by the whole court and nothing lived in between. gameplay. swayOf reads the swipe's length as a continuous 0..1 (TUNE.flickMin..flickFull, px) exactly as
the SERVE already did, and doHit spends that instead of ax. Keys have no length, so they keep
the full lane, and the AI's aim (no mag) is untouched.
...and the out was tightened once more (2026-08-18, "make it a little harder to hit the out"):
flickReach 0.62 -> 0.52, halving the worst-case rate from ~15% to ~7% and pinned by the test. The
shape is unchanged — a perfect flick still never sails, a badly timed full swipe still can — but a
mistimed one now lands NEARER THE MIDDLE rather than nearer the line, which is the only half of that
trade a player who cannot see his own timing can do anything about. flickReach is the out dial;
aimError is shared with the opponent's returns and with every aimed shot in the gore modes.
This is also what hands the OUT back to the player. Oliver: "the number one reason i die is
that i myself keep firing balls into the out." Going out was never his choice — every aimed shot
took the FULL width and then err() on top, so the dice decided. Width follows the swipe now, so
the risk band has a floor the player can stop short of: below (line - aimError) / (flickWidth * flickReach) ≈ 0.7 of a full swipe, the worst mistiming in the game cannot put the ball out, and
above it the old risk curve is untouched. The tested contract from the pass before this one still
holds — a perfectly timed flick never sails, a badly timed full one can.
THE AUTO-RUN OWNS THE LEGS, so the drag does not (2026-08-18, Oliver: "could it be that when
playing touch on mobile zombie tennis and tennis i am still able to manually move by dragging? in
autorun this must be disabled" — it could, and it was). It was not a harmless second option: a
manual axis SUPPRESSES the assist (the !mx && !mz gate in the movement block), so holding a drag
traded a full-speed run to the intercept point for a 0.35x shuffle aimed at wherever the thumb
went rather than at where the ball will be. Walking was strictly worse than standing still, which is
most of "i tend to not be able to reach the balls". input.js stops writing touchAxis under
rallyFeel(); the drag keeps its one rally meaning (release it quickly and it is the aimed swing),
keys stay a deliberate desktop override, and GORE keeps its drag-walk — there the assist recovers
depth only and holding your own artillery lane is the point.
THE AIM IS DRAWN WHILE THE THUMB IS STILL MOVING. Oliver asked to "visualize the strength of my
flick at flick-time", and he was asking for the half the placement ring cannot give: the ring is a
RECEIPT, it arrives with the ball already gone. G.swipe (set by input.js during the drag, cleared
on release) marks where the ball is being ASKED to land.
It is a cloud, not a diagram. The first cut was a ring on a ray from the player's feet and it was rejected on sight ("pretty bare bones... the line from the player to the indicator must go away, it is distracting"): a line drawn across the court is a HUD element laid over the game. What is there now is a soft stain on the ground in the same procedural register as the dust and the blood — it GROWS with the length of the swipe and it WARMS as that ask approaches the width no timing can keep in. Three things were learned making it read, all of them the hard way:
- metres, not pixels — sized off
p.sso perspective shrinks it with distance. A pixel radius looked fine at the baseline and was an 8-px smudge at the far corner, which is where it lives. - a stain, not a glow —
globalCompositeOperation = 'lighter'turned the red end YELLOW over green grass, and the hue is the entire message. - the ramp is pale lime -> gold -> red, not green -> red — green on green is invisible, so the
obvious ramp showed nothing across the whole safe half of its range. The cool end carries its
contrast in LIGHTNESS, which is what makes it read on grass, clay and slab alike.
The threshold is not a flip: the colour is already gold before you reach it, so "too hard" is
something you watch coming. It is OFF as of its first playtest ("hide the indicator for now")
—
TUNE.aimCloudgates the whole block and the tuning panel can flip it back live. Kept rather than deleted: it is the only thing that shows the gesture's strength before the ball leaves, and the lesson it teaches has no other teacher. Shown fromTUNE.flickMinfor as long as the drag lives — since the crossing-fired flick (below) the vector stays live until contact, so there is no release deadline for it to lie past. Rally modes only (rallyFeel); gore's flick free-aims at a body, not at a lane. The placement ring turnsUI.badwhen the shot really is out, so the two rings together ARE the lesson: what you asked for, what your timing gave you, and the difference between them.
...and the flick fires the moment it IS a flick (2026-08-20, Oliver: "trying to move the
approaching ball with a flick to the left is almost impossible ... standing closer to the grundlinie
and more right from the center eases this restriction"). The aim math was symmetric to the
centimetre — the defect was LATENCY. The swing waited for the RELEASE, so the whole gesture ran as
dead time on top of the 60 ms windup, and ~120 ms of lateness drops contact quality 1.0 -> 0.08,
where flickReach halves the width and aimError drowns what is left. A LEFT swipe is the slowest
direction a right thumb has (~250-350 ms vs ~150 for right), the service line leaves only ~700-870 ms
of budget against even the slowest rung (~1.1-1.3 s at the baseline — measured), and any swipe over
FLICK_MS (300 ms) was silently DEAD: no swing, no feedback, ball passes. Left + service line was
exactly the slowest gesture meeting the shortest budget. Two changes, rally modes only
(rallyFeel), input.js + one line in trySwing: the swing fires when the drag crosses
flickMin (in G.phase === 'rally', once per gesture) rather than at release, and the aim
stays LIVE — trySwing reads G.swipe at contact, so the ball takes the vector under the thumb
NOW; the released flickAim only carries it when the ball arrives after the finger lifted. The
FLICK_MS deadline is gone with it where the crossing can fire — it survives only where it cannot
(gore's free-aim flick, the serve-phase strike), where a late release must stay a rescue drag. The
serve gesture, the duel and gore are untouched; keys unchanged. Verified with real touch events:
held-swipe contact aims exactly swayOf, a 360 ms swipe (previously dead) swings and aims, and a
release-before-arrival still aims through takeFlickAim.
...and an aimed shot keeps its DIRECTION (2026-08-20, same report, second round — the crossing
fire alone "did not feel much different", and his sharpened observation was that the flick only
seemed to work when the opponent already stood where he aimed). Measured with the opponent
teleported at contact: the aim never tracked the body — no magnet exists — but at rushed quality
the SYMMETRIC scatter (±aimError·(1-q)) outweighed the quality-shrunk aim, so a mistimed left flick
landed anywhere in a ~4 m blob around "slightly left": usually centre, sometimes right, and the eye
anchors that on wherever the opponent stands. The opponent's position was a proxy for preparation
time (a body parked wide = its return flew a long slow diagonal = time = quality). The dial:
TUNE.aimTrust — an OUTWARD scatter roll is untouched (a committed mistimed swipe still sails
at the exact tuned rate; flickReach stays the only out dial) but an inward roll keeps only
aimTrust (0.3) of itself, so bad timing costs width, never the direction that was asked for.
Player lane shots only: the opponent's symmetric scatter is its width mechanic, and gore's
free-aim never used lanes. Verified in real rallies at worst-case q≈0.08: 14/14 rushed left flicks
landed left (−0.8..−3.5), 9/9 right flicks right — previously the same condition was a coin toss.
The suite pins the direction guarantee and the knob relationship (aimTrust·aimError must stay
under a committed swipe's worst-case aim). Whether placement should also BEAT an unwounded body
more often (aiRead on wide balls — "dial 2") is open, awaiting the phone verdict on this one.
...and the flick aims RELATIVE TO THE PLAYER (2026-08-20, third round, and this was the core
all along: "when the opponent and the player are both standing in the left service box it is
virtually impossible to flick the ball into the left out. no matter how hard/good I flick"). The
lane was ABSOLUTE — a full flick asked for flickWidth off the court's CENTRE — so the gesture
saturated near a sideline: from x −2 a perfect left flick moved the ball under 2 m and could never
pass the line (by design, 3.95 vs 4.115 — and the better the timing, the firmer the cap, because
scatter was the only thing that could cross). The felt "left is impossible / right works" was
never left-vs-right: it was toward-the-near-sideline vs across-the-court. TUNE.aimAnchor:
the aim's anchor slides from the court centre to the player's own x as the swipe commits
(anchor = ch.x · |sway| · aimAnchor), so a flat tap is exactly what it always was and a full
flick is a full ±flickWidth sweep FROM WHERE YOU STAND — including past the line. Out-by-aim
exists now and is a choice (his explicit direction: "not about the possibility of the ball
going out but rather the aiming itself"); the placement ring already turns red for it. 0 restores
the old absolute lane, blendable, panel-tunable for live A/B. Player rally lanes only — the
opponent's lanes and the pOut invariants are stated from centre and unchanged there. Verified in
his exact scene (both bodies in their left boxes): full left flick lands −5 (the alley), half
swipe −3.07 (hunting the line, in), full right +1.93 (a full sweep measured from him).
...VETOED the same day and replaced by the wrong-side guard (2026-08-20: "no that's way too
difficult now - I am dying constantly of out. revert it."). The relative lane is gone
(aimAnchor deleted; git has it if ever wanted). What survives is the answer to his follow-up —
"the ball sometimes actually flies a little to the right, although I flicked left. how can this
be?" — which is the absolute lane's blind spot stated the other way round: a mistimed left
flick's target sits only slightly left of CENTRE, and that is RIGHT of a player standing in the
left box, so the ball visibly flew rightward out of a left flick. The aimed target is now
clamped to the player's own x on the flicked side (bounded at ±flickWidth, so a body parked
in the alley clamps to the corner and can never conjure an out): from the left box a left flick
goes at least down your own line, from centre it can no longer cross centre, a perfect corner
flick and the crosscourt sweep are untouched, and the outward scatter tail — the whole tuned
out-risk — is unchanged. Verified in his scene: ten rushed weak left flicks landed −2.0..−2.7
(previously targets −0.7..−1.3 — a rightward flight); the clean full flick still lands −3.92.
The umpire and the boy. aside() refused during any interlude and the match runs on a permanent
one, so he was silently muted for the entire mode — the one mode that is literally a tennis match he
is sitting over (G.interlude && !G.match now, the same shape as sendBoy). Six dry lines: two on
taking a limb, two on your blood, one when it is nearly gone, one on felling the thing, all behind
the shared asideEvery cooldown. The mop boy comes out between games, to the FAR court
(sendBoy(0, 4.5, true) from matchGameWon) — that is where one body has been bleeding through
four wound stages, and it keeps him out of the near court where you are about to serve. Between
games only; never mid-point.
Opponent serve games were almost entirely switched off rather than missing: setupPoint's
tennis branch already placed a non-player server and armed G.aiServeT, and update() already
tossed and struck for O — both gated on G.mode === 'tennis'. What was genuinely new: service
alternates at each game boundary (the match calls advancePoint directly and the flip lives in
tennis's pointTo, which the match bypasses — the same shape as the G.serveNum trap below), the
zombie takes O's mark when it serves so the picture and the ballistics agree, and the serve POSES are
named onto it (bounce → toss → serve). Known simplification: the interlude's line-call branch
has no serve box and no second serve, so a missed opponent serve is simply the player's point. Add
faults when it matters.
CLASSIC TENNIS is a real mode, not the opener with the turn switched off. Oliver: "if the core
tennis is good everything else is good." Six games with a two-game lead is a set, TUNE.classicSets
is the match, no tiebreak (marked ponytail: — add it when a real match stalls at 6-6). The CPU
stops rubber-banding there: aiFlubLoss exists to get a new player past the opener and would have
him in pieces by the second set. It has its OWN ENDING with no corpse in it (G.done = {won, t});
all three endings share one render block, because three copies would drift the first time one moved.
THE RECORD SHAPE — read this before touching stats.js. One character m on the run record
(gore / zombie tennis / classic; absent means gore, so no migration). The record's two headline
slots carry each MODE's own two numbers:
| mode | g |
s |
dead |
|---|---|---|---|
| gore | games survived | sets banked | died |
| zombie tennis | opponents felled | games played | died |
| classic | games played | sets won | match LOST |
g is what everything ranks on, so each mode's real measure goes in that slot and there is no second
best stored anywhere to drift. read(period, runs, mode) filters; the stats screen is one function
with three sets of words. The world board stays the gore mode's — it ranks kills in a run, and a
five-body match would post a "5 kills" row next to a 300-kill massacre. A tennis board would rank
FELLED with points won as the tiebreak; that is a server change.
The sprites. The match opponent has its OWN art (z_r* and the nine families beside it), drawn
off the LIVING opponent's poses: ready stance, shuffle pair, forehand, at each wound stage, both
tours, plus the serve and the fall. The frame is picked off racketAI's own pose, so the picture
cannot drift from the behaviour. z_c* (the turned CPU) is untouched and keeps its shamble — in the
gore mode shambling is the right thing for it to do.
Traps this mode has already sprung — do not re-learn these
autoAssistis 0 onpointer: fine. The desktop pane has no auto-run. The match floors it withmatchAssist, andassistK()is shared by the mover ANDintercept— computing reachability with the raw knob made the planner answer "run home" forever.- The contact chain is three separate things: LEGS (
matchAssist) → DESTINATION (assistKinintercept, plus theanyHitshort-ball candidate that chases dying rollers) → REACH (matchReach). Each was a separate defect found in a separate recorded session. ball.lastHitmust be stamped bytossBall. A stale'O'on a toss from behind the baseline read as the opponent's passing shot: a point and blood PER TAP. Fixed, but the shape recurs — anything that revives the ball must own it.G.serveNumis only reset by tennis'spointTo, which the match bypasses.matchReady()resets it; a new mode with its own point boundary must too.- A mode gate written as
G.mode === 'tennis'excludes the match (it is'splatter'under a permanent interlude) and INCLUDES gore's prologue. Reach forG.match/rallyFeel()instead. stats.bankedis only cleared by a KILL, and classic tennis has none — so the second and every later classic match of a session was silently swallowed by the double-bank guard.beginRun()is the reset now. Any future mode with no kills in it needs that path.
Do not
- Re-add regicide to the match (killing the body with a smash) — removed deliberately; it made every hard serve a free unaimed kill and smeared the score-is-the-body relationship.
- Touch the gore mode's contact/recovery rules while tuning the match.
matchReachand the centre-recovery are match-only ON PURPOSE; gore splatter's tight contact and depth-only recovery protect the sidestep. - Put a body-shot thrower in the match.
shots[]and the PARRY are built and unused here, and it looked like the cheapest depth in the mode — Oliver scratched it, 2026-08-17. Do not revive it without him asking. - Turn
drillOneShoton as a default — it skips everyZWOUNDstage, so the roster's best art never renders. - Revive the TRIAGE DRILL without a new idea. It still works exactly as designed and it failed the only test that matters: "i think the normal player does not grasp this." Four designs died learning that an invented rule needs teaching; the rules a scene already implies do not. That is why the match is tennis.
Expansion ideas that survived discussion (also in LATER): the tournament frame (QUALIFYING → ROUND OF 16 → THE FINAL vs THE CHAMPION), the Hawk-Eye challenge as an earned continue, sudden-death deuce past a given opponent, doubles, and multiplayer.
Session log: a lane is free when its body is gone, not when it is far away (2026-08-16)
A seed-sensitive red suite (SEED=1234 node test.js failed on the drill's "spaced after the walk
in" assertion) turned out to be the assertion telling the truth: two drill bodies genuinely walked
through each other. The spawner measured its best-of-9 lane spacing only against bodies with
z < drillNearZ (9 m) — sound for a court where bodies wander, wrong for this one, where a lane is
HELD for life and speed is stamped at spawn. A body 12 m down still owns its lane; handing that lane
to a faster arrival does not create a spacing problem at spawn, it books an overtake for ten seconds
later, mid-court, as two sprites occupying the same metre. The fix is the filter: spacing is measured
against every live drill body, drillNearZ is deleted (it had no other reader). No retry loop, so
the stream still cannot stall.
Measured, 60 seeds × 150 s of drill each: before, 7 seeds produced overlaps, narrowest clearance 0.00 m; after, zero overlap frames, narrowest 0.46 m (the test asks for > 0.3). Peak on court is still the cap of 7, so the fan is wider, not thinner. Suite green on 20260811 / 1234 / 777 / 99 / 424242 / 8, and on 299 of seeds 1..300 — the one straggler is unrelated and still open (see below).
The one straggler the same fuzz found, and the arithmetic to copy for any rate gate here.
SEED=80 failed "opponent one returns a slid serve: 5/10". The receiver is innocent: measured over
200 trials a seed across eight seeds, the real return rate is 0.85..0.92. The gate was simply too
small a sample — asking 6 of 10 at p ≈ 0.88 fails with probability ~0.004, one seed in 230, and 300
seeds duly produced one. n is now 40 at the same 60% bar, which puts the threshold five standard
deviations below the true rate while still failing instantly if the receiver regresses toward the
0-in-24 the test was born from. It costs nothing — a returned serve breaks the frame loop early, so
the whole suite still runs in about a tenth of a second. Green on seeds 1..400.
The general lesson: a pass/fail gate on a random rate needs its sample size derived from the rate,
not guessed. If the true rate is p and the bar is b, n has to put b several standard deviations
(sqrt(p(1-p)/n)) below p, or the gate is a slow coin flip that trains you to re-run the suite.
Session log: THE NETPLAY LOBBY — a link to send, a code to type, a name to be (2026-08-15)
Oliver: "multiplayer should be easier to setup — a share with link next to the code; also entering
the code is done in some plain js confirm dialog thingy, this could be embedded more nicely;
somewhere when hosting or also when joining the player should name themselves." All three are one
screen (#vs, index.html + THE LOBBY in main.js); the protocol underneath is untouched.
- THE LOBBY IS A MENU SCREEN, and that is the whole structural change. Hosting used to call
start('classic')first and show the room code in a CANVAS modal over an empty court — which cannot carry a share button, cannot be typed into, and turned a mistyped code into a CONNECTION LOST card over a match that never began. The socket is opened from the menu now and the game stays parked behind the overlay until the room actually fills.netHooks.pairedis what takes the court, called at the top ofnetStart— one line before everything that writes to it, so the kit/tour selectionstart()performs is already done when thelookmessage reads it. Two more hooks joinintoSplatter:room(the code landed) andlost(returns true when the lobby said so itself, which is what suppresses the card). Headless leaves them null. - SEND THE CODE — and no link (settled same day).
navigator.shareis the phone's own sheet — the friend is in WhatsApp, not in a room reading letters aloud — withclipboard.writeTextas the desktop fallback. It shipped as SHARE THE LINK for an hour and the link came straight back out: a URL only opens the INSTALLED app on Android (the manifest's scope covers the site, so its WebAPK claims these URLs), while an iOS home-screen app can claim nothing, so on an iPhone the invitation opened a second copy of the game in Safari — its own storage, none of the name they picked in the app. Four letters typed into the app they already have is the honest version until this is a real app with universal links; the#net-ABCDdoor still exists and is exactly what a deep link will carry the day there is one, nothing merely sends it now. That door prefills the lobby rather than auto-joining, so the joiner still sees the name they will wear. - ONE NAME PER PLAYER. The field is prefilled from
stats.savedName()and written back through the newstats.saveName(whichsubmitnow shares), so the world board's name and the netplay name cannot become two things. It crosses on the EXISTINGlookmessage asn— no new message type, and an older build simply sends none. The scoreboard names the person (render, the names block) in both netplay modes: FRIEND until the look lands, never CPU. A stranger's string lands in an 8-character slot, so it is normalised at the RECEIVING end through the sametidyNamethe board andserver.py'sclean()agree on. - Failure is a typo now, not a card. A short code never opens a socket;
goneputs NO SUCH ROOM — CHECK THE CODE over the picker with the field still there. CANCEL and the header chevron bothendNetVs, so a room you walked out of gives its socket back. - Verified through the real relay in two panes: host → share link → join → both take the court, host serves, the board reads YOU / GUEST, and the death-match room rebuilds both courts the same way (blood pips, the friend as the living opponent, no roster body). 96 tests.
- Smaller: the JOIN button is the crimson plate stretched to the field's own height (
#vs .joinrow .btn— a bare.joinrow .btnloses to#vs .btn's margin, and a margin on a stretched flex item is exactly the 8px it was coming up short by);.tinis now the one typed-field look, worn by the name prompt and both lobby fields.
Session log: gore opens in the gore, the theme comes back, three boards (2026-08-15, same day)
Oliver's finetuning pass on the batch below.
THE GORE MODE STARTS IN THE GORE
"the splatter gore should not have the normal tennis intro anymore, we should just start right in splatter mode." The prologue was a game of real tennis you had to WIN before the mode you picked would begin — a reveal the first time, a toll booth every time after.
gameplay.startGore() is the direct door: it lands on the same state the transition's last beat
lands on (extracted as enterSplatter, so the two doors can never drift), sets everTransitioned
so the cinematic cannot fire behind it, and spawns the turned opponent as your first body — he is
the man you came to play and the mode's premise is that he is already one of them. The transition
is NOT deleted: it is still what forceZombies / T.forceZombies() plays, and it is still the
best thing in the game to look at. It just no longer stands between the menu and the mode. The mode
poster stopped promising a match that turns, because there is no longer one.
main.intoSplatter — the back room's "get me into splatter" hack — now calls startGore() too,
which deletes its 900 hidden update() calls. That loop fast-forwarded the whole eight-second
cinematic inside a single frame, firing every sound it makes at once.
THE MENU THEME COMES BACK, AND THE COURT STAYS ON THE COURT
"after some games — or returning to the main menu via the close button — it does not play the background music anymore but you sometimes still hear the court background noise."
Two halves of one bug, and both are loopBed's early return. It no-ops once a file is playing,
so menuMusic() could only ever START the theme and never bring it back from the duck start()
put it under — the menu was silent for the rest of the session from the first run onward, and
quitting was the reliable way in because the death summary is the only path that ever called
menuDuck(false). Meanwhile nothing ducked the court's own two beds at all, so the ambience
followed you onto the silent menu.
menuMusic() is now the one call that means "we are in the menus": it starts the theme, un-ducks
it, and ducks both court beds. ambient() and zombieMusic() un-duck themselves for the same
reason — the SECOND run would otherwise have been played in the silence the menu left them in.
A BED MUST RESUME THE CONTEXT WHEN IT FINISHES LOADING, NOT ONLY WHEN IT IS ASKED FOR. The
second half of the report — "sometimes, when initially entering the game and tapping somewhere,
the background music does not start; I suspect the sound is not yet loaded" — and he had it
exactly right. The tap unlocks the context and starts a 768 KB fetch; the src.start() that
follows happens seconds later inside a promise, with no gesture anywhere near it. iOS suspends an
idle context in that gap, and a source started into a suspended context is silent forever —
nothing on the splash calls audio() again, so the theme simply never arrived. loopBed now calls
audio() a second time in its .then, which is allowed because the first tap already unlocked the
context. That is why it was intermittent: it only bites when the decode outlasts the browser's
patience, i.e. a phone on a cold cache. A visibilitychange resume covers the same trap from the
other end (a phone that locks ON THE MENU came back silent; during play every pock resumes it for
free, but a title screen makes no sound of its own).
...and each of those un-ducks ITS OWN BED ONLY. The first cut had ambient() un-duck both
court beds together, which put the splatter music back on in classic tennis for the rest of the
session — Oliver caught it on the retest: "after playing the splatter gore, the zombie background
sound also keeps playing in the normal tennis game." LEAVING the court is one event and ducks both
beds; ARRIVING is not. The ambience belongs to every mode, the zombie bed belongs to the gore, and
only enterSplatter may bring that one up.
The rule, for anything added here later: a bed's start function must also un-duck it, because
starting it is a no-op the second time and ducking it is not. audio.bedTest exposes the duck
set to the suite (same idea as netTest), which is what makes this testable at all — the beds
never load headless, but the INTENT is the thing that was wrong.
THREE WORLD BOARDS
"the world rank is still about the splatter gore which is not the only thing — we need something similar for the other modes."
stats.BOARDS is the only place that knows what each board ranks, and the server does not know at
all: it sorts k then g within a mode and nothing crosses.
| board | ranks | second column |
|---|---|---|
| SPLATTER | kills in a run | games survived |
| TENNIS (arcade + cup) | opponents felled | games |
| CLASSIC | games taken off the CPU | games played |
Classic needed a number worth ranking — every win is exactly two sets, so a board of 2s is a wall
of ties. G.classicWon counts the games YOU took and rides in the record's s slot, which held
sets until now and which nothing has ever displayed (the sets line on the death screen is
gore's; read() has never looked at s). The name prompt is per-mode too, gated on the board's
own number rather than on kills — the old run.kills > 0 test silently barred classic (no kills)
from ever reaching it.
Server: rows carry m, GET /api/scores?m=g|z|c filters, POST keeps one row per name per
board and trims KEEP per board so a busy mode cannot evict a quiet one. Name ownership
stays GLOBAL — one person owns their name on all three, or a stranger could take it on classic
while you hold it on gore. Rows written before the split carry no m and are gore rows, which is
the same convention the client has used since the stats screen split, so there is no migration.
THE GORE MODE GETS HARD AGAIN
"in splatter gore it now gets too easy to just play through. Maybe we should reduce the barrels again — pretty early in the game every won game gives a barrel. The splatter gore should be unbelievably hard towards the end."
He is right and the arithmetic said so. barrelEvery 3 / barrelRamp 8 drove the gap to one at
game 16 and left it there, so the entire back half of a run paid out a weapon for every game won —
while every difficulty ramp in the file reached its ceiling around game 25 and stopped. A run got
richer after game 25 and never more dangerous.
- Barrels: 5 / 14, and a new
barrelFloorof 3. The ramp survives (the late game does need more inventory than the early one), it is just slower and it STOPS.gameplay.barrelGap()is the curve, exported and pinned by a test, because this is exactly the kind of one-liner that quietly becomes an income again. zSpeedMax2.9 → 3.6 — shamblers keep gaining speed to ~game 28 instead of 22.nastyMax0.75 → 1.0 — by game 34 there are no ordinary shamblers left in the mix at all.- Ball machines scale:
machCap()is 2 + one permachMaxPergames, capped atmachMaxCap. The one kind of late pressure that does not crowd the ground — they hold position and shoot, so the court stays readable while the number of things that can reach you goes up.
The body COUNT ceiling (zMaxCap) is untouched and stays untouched: it is a readability limit on a
270 px screen, not a difficulty dial. What escalates now is what the bodies ARE and how fast they
arrive. Feel-pending: these are dials, and the end of a long run is the one part of this game
that cannot be judged from the numbers.
Session log: the roster doubles, the draw is made on screen, and netplay stops locking (2026-08-15)
Oliver's seven-item batch. All of it is on STAGING and pending his phone verdict; items 3-5 in particular need two phones on cellular, which is the only place the bug they fix was ever seen.
1. SIX NEW OPPONENT FAMILIES, and the ladder goes 12 -> 18 rungs
"expand the roster for the zombie tennis, use the proven way of generating the roster with the refcheck... head coach, stringer, strawberry seller, chair umpire, spectator, roller man."
Rolled through _famroll.py -> _zsheet.py exactly as the WHICH-TOOL table says, keys hc sg sb cu sp rm, each anchored on the body it already has in the gore mode (z_coach1, z_string1,
z_berry1, z_chair1, z_s1, z_k1) — the rung and the thing that hunts you stay one person.
186 new sprites, 6 new head icons, _heads.py SRC/BOX rows for all of them. Placed by HIERARCHY
rather than stacked on the end (spectator 3, roller man 5, seller 9, stringer 10, chair umpire 12,
head coach 14), so the ladder still reads as the club's staff from the bottom up, and the
champion took the step up Oliver asked for: 1.45 speed / 1.42 pace / 1.36 width / 0.38 flub,
against 1.35 / 1.30 / 1.30 / 0.50. CUP_ROUNDS bands widened to match, LADDER is exported so the
ramp test and the draw screen read the table instead of a pinned 12.
TWO PIPELINE LESSONS, both paid for in rolls:
- THE RACKET IS THE POSE, NOT A PROP — say it out loud. All six of these characters carry
something in their anchor frame (a ball basket, a coil of string, a tray, an umpire's chair, a
folding chair, a court roller), so each description had to say the thing is gone — and the model
read "he carries nothing in either hand" as covering the RACKET too. Two families came back
playing tennis bare-handed and the strawberry seller returned every shot with his tray. There is
now a permanent clause in
_famroll.prompt()saying the racket belongs to image 1's pose and is never removed or replaced. Describe a prop's REMOVAL by naming the prop ("the tray image 2 shows is NOT part of him"), never by talking about what his hands hold. - A prop at head height defeats
_heads.py's shoulder-widening rule every time. All six needed a handBOX; the automatic crops came back 88x18, 36x17 and 35x16 — a letterbox of scalp. If a new family's head icon is a thin strip, that is the diagnosis, and aBOXrow is the fix. - The club pro finally has a head icon too (
z_rhead.png, cut fromz_c1— the turned opponent IS him), soSPRpreloads all of ZFAMS and the draw screen's portrait column is consistent. ENEMIES['L' + i]was reading BOSS_ART for the NAME and OPPONENTS for the line — the same answer only while the two lists were the same list, and they were decoupled on 2026-08-13. The body-count screen has been printing THE PHYSIO over the mascot's line ever since. It reads one list now, which is also what stops rungs 11-18 having no row at all.
2. THE DRAW — the tournament's bracket, settled in front of you
"when starting the tournament there should be a view of the tournament hierarchy but only the finale is settled... random browsing through the roster in the individual spots which begins fast, gets slower and then settles on a random character, so that every tournament is new."
A new menu stage (#bracket, main.buildBracket), not a court-owning mode: SERVE UP lands here
when the picked mode is cup, four rows roll their own band of the ladder at a gap that grows with
p², they land in order at 1.5 / 2.05 / 2.6 s, and ENTER THE DRAW is disabled until the last one
stops. The final wears its settled look from the first frame — it was never in doubt.
THE SCREEN IS THE DRAW, NOT A TEASER. startMatch(cup, draw) takes the settled rungs and
cupDraw reads them off instead of rolling its own, so the face you watched land in ROUND ONE is
the body across the net. A screen that dealt a different tournament from the one it just showed you
would teach the player that the whole ceremony is a lie. cupDraw still validates each rung
against its round's band and rolls honestly if it does not fit, so a stale or hand-passed draw
cannot deal THE CHAMPION in round one. #cup (the back door) passes no draw and behaves as before.
3-5. NETPLAY: the deathmatch lock, the court, and the ball that froze
The lock — "sometimes both players stand on the same side and nobody can serve" (cellular).
ROOT CAUSE FOUND AND IT IS ONE LINE: the mid-flight early-decide in update() was missing the
!G.net gate its bounce-1 sibling in onBounce has carried since the netplay session. It exists to
predict THE CPU'S LEGS; against a human on another phone it made the STRIKER a judge of its own
outbound flight (resolveInterlude sends the verdict), so on a slow link both phones scored the
same rally. The two scoreboards then sat a point apart forever — and that is not cosmetic, because
setupPoint reads score.pts for the deuce/ad side and score.server for who tosses. One point of
drift puts both players on the same half of their own court, each waiting for the other to serve,
with no way out. The one rule is that the receiving phone judges; this was the last place still
breaking it. The 2026-08-14 log claims the early-decide was gated — only the bounce-1 half was.
Belt and braces, because that failure mode is a soft-lock rather than a wrong number: the HOST's
10 Hz position stream now carries its scoreboard while it sits in pretoss, and the guest adopts
it mirrored (netSync). Gated to pretoss ONLY — nothing is mid-beat there and any verdict in
flight has long landed — so a live rally is never reconciled underneath itself. One authority, no
new message type. score.games is deliberately NOT synced (ponytail): it costs a wrong number on a
board rather than a court nobody can serve on.
The court rides the room. ?court=<id> at creation, stored beside death on the host's room in
server.py, carried in the start message, applied by netStart -> selectCourt. Safe mid-run: the
bounce pair is copied into TUNE and the backdrop cache keys on court.id. Two phones playing one
rally on two surfaces disagreed about the bounce as well as the picture, and the guest never chose.
A called ball plays out. "as soon as the ball goes out the gameloop stops — it should just play
until it came to a natural halt." Every scoring path used to switch the ball off where it stood,
which on the STRIKER's phone is a whole court and a latency away from the call. ball.scored is the
new flag: the flight is decided, physics keeps running, and the ball dies where a real one would —
by rolling to a stop under the existing |vy| < 0.8 dead-ball rule. endFlight() in gameplay.js is
the one place that decides which it is (netplay rolls, everything else stops dead, so no
single-player feel changed), and onBounce returns on ball.scored at the top, which covers every
call site in one line. The three consequences further down the physics block (the dead-roll branch,
the baseline crossing, the retire) carry the same guard. setupPoint clears it.
6. THE NIGHT COURT
A COURTS row (gloom, THE NIGHT COURT) plus one generated menu tile. It needed no new palette
machinery — its TENNIS palette is simply darker than every other court's SPLATTER one, and
paintBackdrop already paints every line in UI.cream, so the lines read as chalk under
floodlights with no new knob. It borrows the hard court's stands and boards rather than growing two
more sheets. The one thing it did need is dim: a fraction the STAND is taken down by before
the rot even starts (render.standDim, 0.62 here and absent everywhere else). Without it a
floodlit blue crowd sat above a near-black court and the picture stopped being a night. standDim
is ONE function because the number is applied twice — to the cached sheet and to the live clap
columns drawn over it — and two copies of it is a stand whose clapping columns pop brighter than
the rest of it, the same class of bug as the two crowd frames drifting apart.
7. CAMERA FLASHES IN THE STAND
render.tickFlashes, an OVERLAY like the clap ripple and for the same reason: the backdrop is one
cached blit and anything animated inside it repaints the whole scene every frame. Clocked off
G.idleT (the function has no dt, and idleT is the clock that never stops). A flash is a hot core
plus a cold bloom, TUNE.flashLife 0.16 s — deliberately short, because a flash you can look AT is
a lamp and one you only catch is a photograph being taken. The first cut drew a 6 px disc and read
as a floodlight; it is 1-2 px now. TUNE.flashEvery (5 s, jittered 0.35x..1.95x) is the one knob,
halved again while crowd.cheer runs — which is the "slightly increase during applause".
Session log: MULTIPLAYER — the live 1v1 socket (2026-08-14). PHONE VERDICT PENDING
Oliver: "alright multiplayer" — and, offered the recorded ghost-match step one, he picked the LIVE SOCKET instead, plus "a new button with generated art in the main menu — the possibility to start or join a game". Built to the architecture LATER item 7 recorded; verified end to end in two browser panes through the real relay (pair → serve → mirrored flight → verdict → both scoreboards agree on the same point from opposite perspectives). 73 tests.
- It is CLASSIC TENNIS between two phones —
G.classicwith the O opponent driven by the other phone instead of the AI. The mode poster is VS A FRIEND (mode_vs.png, generated in the zombie poster's night-court style); its second step is two chips, HOST — GET A ROOM CODE / JOIN WITH A CODE, and either chip IS the start — a room needs a socket, not a setup screen.#nethosts and#net-ABCDjoins from a URL. The room code lands in a modal over the frozen court (an empty court waiting for a stranger is the calmest court there is). - The relay is ~110 lines of stdlib in server.py (RFC6455 handshake + framing,
/ws). It holds NO game state: mints a 4-letter code (no I/L/O/U/0/1 — codes are read aloud), pairs exactly two sockets, forwards text frames verbatim. The host serves first on both scoreboards. 75 s socket timeout against vanished radios; the client heartbeats every 20 s. - The sync unit is THE SHOT (
src/net.js): each phone simulates its own court with itself as P; a strike sends ~9 numbers andmirror()(x → -x, z → COURT.L - z) rotates them half a turn. The 10 Hzposstream drives the O sprite and carries no authority. - THE ONE RULE: the phone the ball is flying toward is the only judge of that flight — line
call, service box, double bounce, its own swing. The striker's outbound ball is picture
(gameplay's tennis bounce block returns early on
G.net && lastHit === 'P') and the verdict arrives as a message:shot(returned),fault(re-serve),pt(point, winner relative to sender). This is what stops two sims a latency apart double-scoring a rally — the striker's copy would call a double bounce before the defender's swing had happened. Anapplyinglatch stops a received verdict echoing back out. Six tests pin the rule from both seats. - startNetVs/endNetVs live in beats.js under the ownership contract, and
G.netjoined every other start*'s refusal list. Serving is barred while unpaired or dead (keyPressed), O's auto serve and rally AI are gated off,restartRuncloses the socket. - Honest limits, all deliberate (ponytail): NO reconnect-and-resume — a dropped radio is a
CONNECTION LOST card and a reload (the recorded plan calls reconnection "the bulk of the real
work"; a prototype must not start there).
prompt() is the join UI. The scoreboard still says CPU.(both gone — see THE NETPLAY LOBBY above.) A return snaps the ball to the mirrored strike (~latency of drift eaten in one hop; the flight swallows it). Runs bank as classic runs. The wound-decay hook (losing IS zombification) is the recorded next step once the feel survives a two-phone playtest. - Test on staging with two phones — the relay deploys with the ordinary push, nothing to provision. Coolify's proxy passes WebSocket upgrades as-is.
SAME DAY: THE HOST PICKS THE MODE, and DEATH MATCH is the second pick. Oliver: "the one
creating the room could decide whether it is classic tennis or death match.. in deathmatch the
wounded opponent would show increased dynamic bleeding before exploding." The VS second step is
three chips now (HOST — CLASSIC TENNIS / HOST — DEATH MATCH / JOIN WITH A CODE); the host's pick
rides the room (?death=1 at creation, carried in the relay's start message), so the joiner never
answers a question. On pairing BOTH phones rebuild the court into the match machinery — the same
verdicts drive resolveInterlude/matchPoint instead of pointTo, so blood, the mop beat and
the scoring come along unmodified. THE OPPONENT IS THE FRIEND, NOT A ROSTER BODY (his retest:
"the opponent reads as zombie — it should be the normal opponent", and no SHAMBLER teaser
card): nothing spawns, the living O carries the whole picture, and since the human sprite has no
wound skins, the score reads on him as BLOOD — bleedO() in zombies.js opens an anchor per
point taken off him this game and thickens the flow, love → game point, thin drops throughout
(the settled rule: more emissions, never heavier drops). The game-winning point BURSTS him where
he stands (explode — the human has no fall art, and procedural gore is the sanctioned fake),
the mop beat runs over the gap, and IT RISES AGAIN returns him with the ramp reset. Their won
points draw your blood; bleeding out sends dead — the other phone banks a NOT-dead match run
and holds the win screen. Wired details: d.opp frozen (no roster climb — same friend every
game), racketAI / the early-decide / the empty-court-regicide check all gated !G.net, the heal
beat is a CARD in netplay (the modal froze this world while the other phone's kept moving), and
gushProne() gives the SOLO deathmatch's kill fall the same bleed-out from prone-height anchors
(bleed() stays suppressed there — standing anchors are the "blood fountains midair" bug). #netd
hosts a death room from a URL. 79 tests; the blood-ramp/burst/return loop verified across panes.
Session log: the far court, without spending the lens (2026-08-13)
Oliver: the far-side opponent is barely visible in portrait, and the lens (camZ/camF, already at its measured ceiling — see fitViewport) and world height were the only knobs he knew. Two new ones. VERDICT (same day): the scale curve is settled-in ("really good — helped a ton"), the lean was vetoed ("the camera movement is distracting") and its knobs sit at 0 — see the settled list:
TUNE.farK— bodies draw off a flattened scale curve, the court projects true. The 16-bit tennis trick:s' = s·(sNear/s)^farK, anchored at the player's deepest stance (he is untouched), clamped so nothing nearer ever shrinks. At 0.25 the far-baseline opponent draws ~20% bigger (31 → 38 logical px) and the court gives up nothing. Applied in render.jsfarB(z)to every body AND to every anchor that hangs off a body height (flames, boss HP, muzzle glow, drill chevron, umpire) — boosting only the sprite left the HP bar sitting on the boss's hat. Known residual: the ball keeps the true projection, so at the far baseline it meets a boosted racket ~5px off. 0 restores pure projection.TUNE.farCamY/farCamZ— a broadcast lean while the ball is on the far half (tennis + match only; the gore modes would pump). Eased like aimCam, and pivoted about the player's deepest stance viafOffin render.js — without that pivot 0.35m of camY slides his feet 14px toward the rack, and the apron math promised that room. Measured in-pane: far court rises ~14px and grows ~1.3%, player feet drift ≤2.3px and return exactly. The backdrop cache repaints while the camera eases (~0.35ms/frame desktop, settled key at both ends) — if the phone stutters in rallies, zero these two before touching anything else.
Session log: THE TOURNAMENT (2026-08-13, same day — Oliver picked it over the endless ladder)
The fourth mode tile: four rounds up the dead hierarchy (config.CUP_ROUNDS — each round DRAWS a
rung from its band, the final is always THE CHAMPION), riding the whole match machinery and
differing in exactly three places: who walks on (cupDraw puts the drawn rung in d.opp, so every
ladder dial downstream scales untouched), how deep the body's pool is (matchOpponent multiplies
by the round's need and matchPoint divides the same way, so wound stages SPREAD across the
round — the body enters game two missing what game one cost it), and where a won game goes
(cupGameWon: mid-round the same body plays on; round won is the kill + mop beat + next draw;
past the final, cupWon banks a NOT-dead match run and holds CHAMPION OF THE DEAD CLUB on the
classic win's summary shape). Blood carries; the trainer's heal-per-game is the distance. Round
one is a single game so a run opens fast; deathmatch stays beside it as the arcade mode.
Also this session: the fall ANTICIPATES the pass (a winner past the net with a closed-form
unreachable landing resolves mid-flight — plus the soft-lock the first version shipped: the
decided-latch must die with its rally, see the beats/gameplay comments); the fall table lost its
rise row and tightened its drop; the ghost trail is PACE-qualified so both tennis modes streak
(trailSpeed); and opponent sprites draw brighter via a load-time baked copy
(render.brighten, TUNE.oppBright) — no PNG touched.
Session log: the serve gets stakes (2026-08-13, same day)
Oliver: "the golden mark is just very easy to hit and hitting below it does not necessarily make a worse serve... the flicking does not work here — also I see no option to serve harder." Three mechanical causes found, all fixed; PHONE VERDICT PENDING:
- The perfect window was ~0.3 s of hang time. The 0.12 m snap around the apex, at toss
gravity, held PERFECT for most of the ball's float. Now
TUNE.apexSnap0.05 /apexFall0.55, and the gold band on the apex guide reads the same knob, so the picture tightened with it. - The pace ceiling was the TARGET DEPTH, not the timing. Measured with a replica of serveArc's loft loop: at the old fixed mid-box depth, every serve above q≈0.7 was cut to the same ~36 m/s — quality could not show. Player serve depth now rides quality (flush aims 0.3 m inside the service line, the only depth where flat pace survives). Ladder: flush 49 / flush+hard 52 / mistimed 36 / bad 30 m/s. Opponent depth untouched.
- The strike flick was splatter-only. In tennis and the duel the strike gesture now aims:
horizontal component sways the target toward T or sideline (
serveAimX), upward component is pace (serveHard, withserveHardSpin— the extra dive is what buys the clearance; without it the loft loop cut the hard serve BELOW the plain one). Pace and sway are paid for in scatter (serveHardErr) against the real fault machinery — tap stays the safe second serve. Keyboard: arrows sway, up is the hard one. Verified in-pane with synthetic flicks: 49.4 / 52.5 / 51.0 swayed +1.1 m, all legal at flush timing.
Session log: nothing arrives unannounced (2026-08-11, same day)
Oliver's retest: "the horde is not announced anymore" and "bosses are just there now". Both were true, and both were the same mistake — the announcement fired on the same frame as the thing it announced, so it was over before it registered.
- The horde gets its breather back.
warnHorde()(beats.js) raises THEY KEEP COMING and holds an ordinary 'over' beat forTUNE.hordeWarnT(2.4 s);startHorde()runs when it expires. The cheat (forceZombies) still starts one instantly — it is a cheat. The breather runs on its own clock (G.hordeWarn, ticked in update above the 'over' block), deliberately NOT on the 'over' beat it also sets: any beat that moves the phase underneath it would strand the latch forever, which is themgTlock this file already has a section about.startHorde()returns a bool now, and if the court has been taken when the warning expires the caller re-armspendingHorde— the set gets its horde later rather than losing it silently. - The chapter boss walks on behind an OVERLAY, not a band card: the boss's name in
UI.badoverENTERED THE COURT. It is a modal (a boss stage is guaranteed calm —startBossFightclears the court two lines earlier, so this is on the sanctioned list) with the newm.holdself-dismiss clock,TUNE.bossIntroT= 3 s. A card in the routine-set slot and the routine-set size is why the biggest fight in the game read as the boss simply being there. - The flamer does not trigger a horde (his "vague feeling"). Arming any
kind: 'burst'tin puts you in the same CONTROL MODE a horde does — racket auto-fires, the finger steers you absolutely, no serving — so a flamer or gun genuinely feels like a horde starting. Nothing in the weapon paths touchesstartHorde; with the horde announced again the two are told apart by the announcement. - Three tests added: the wave may not land on the frame of its own announcement, a mode that steals the court during the warning re-arms the latch, and the boss overlay names the boss and hands the court back without a tap.
Same round, the burst weapons. "When firing a burst tin the weapon stops when it has destroyed
all visible enemies. It should just fire on." It did, and the cause had nothing to do with enemies:
both the auto-fire and the steer were gated on G.phase === 'rally', and every beat raised by
clearing the court parks the phase in 'over' (a point, a won game, the mop-up). So the moment the
flamer killed the last body it went silent AND the player froze — worst possible timing, and it read
as the weapon jamming. A burst is its own control mode for its whole len, exactly like a horde:
its own clock is the only thing that ends it. The fire block in updateZombies has no phase guard
at all now, and the movement block runs on G.phase === 'rally' || G.mgT > 0. The settled "the
player only walks during rally" rule is untouched — nothing arms a burst except the player.
Deliberately NOT fixed by forcing the phase back to 'rally' during a burst: that would stop overT
ticking, and a parked beat that never expires is how pendingHorde gets stranded (see the mgT lock).
And the burst countdown is the TILE now, not a bar. It was 1.5 px along the tile's bottom edge —
which is both the rounded frame and, on a phone, the very bottom of the screen. The armed slot's
WELL drains instead: a gold wash across the whole tile, emptying leftward, clipped to the tile's own
rounded path (panel leaves it as the current path) and drawn under the icon and labels. Measured
lit vs empty on the live tile: rgb(88,125,68) against rgb(31,84,49).
Session log: THE COACHING DRILL — the cognitive mode, as a prototype (2026-08-11, same day)
Oliver and his team-mate decided the game needs a different angle alongside the gore mode, after
he reported the late game going repetitive: "something that requires some immediate strategy other
than saving specific weapons for a boss I was killed by last time". Three modes planned — classic
tennis, splatter tennis (this), splatter tennis GORE (what exists). What went in today is the
middle one's feel-test, not the mode: back room only (DRILL TEST, or #drill), no menu entry,
nothing wired to tennis scoring.
Why the late game is flat, for the record (diagnosed before proposing anything, per the
meta-lesson). The optimal move never changes: kill the nearest thing, now. Every interruption is on
a fixed metronome (duel/3, machine/4, horde/6, boss/10, barrel/every game past 16), the director's
dials all bottom out by ~game 25, nothing is scarce once the rack is full — and the zombie side's
score is unreachable by construction: contact is a radius around the player and bodies home on his
x, so anything that could cross the baseline eats him first. The crossing branch in updateZombies
(and the ump_unsportsmanlike line with it) is very nearly dead code. Oliver caught that himself —
the first design direction proposed this session was built on it and had to be thrown away.
The mode. The umpire CALLS a colour sequence aloud while bibbed bodies jog on and take their marks; then they come for you and you put them down in the called order. Colours never called are DECOYS — bodies walking at you that you are required to ignore. Two faults = double fault = the round is theirs. Settled in discussion: audio-first, and shown as a pattern too.
voiceSeqin audio.js is what makes the call possible at all.voice()DROPS a line that would start while the umpire is still talking (one man, one microphone) — correct for unrelated triggers, fatal for a list. It decodes every clip FIRST, then queues them all at accumulated offsets, so the anti-overlap rule is satisfied by construction rather than fought. Five new recordings (say -v Daniel -r 168->afconvert, the existing pipeline).- Four colours, not six, and every bib carries PIPS as well as a hue: ~8% of men cannot separate red from green, and a mode whose entire input is "which colour is that" owes them a second channel. The same pips appear on the HUD strip, so the two readouts teach each other.
- The bib is a DRAWN OVERLAY and it knowingly breaks the sprite rule (
ponytail:marked indrawBib). Six bib sheets x every walk frame is a week of art to answer a question a trapezoid answers today. If the mode survives its playtest,drawBibis the thing to DELETE and replace with a real bib sprite per colour, drawn like a wound skin. Shoulders wider than waist — the first cut flared the other way and every body was wearing a sandwich board. - TWO functions pick a target, and snapping only one of them was the bug.
flickTarget(the slingshot) andzombieTarget(the plain tap) are separate paths; with only the first snapped, fifteen tap-serves in a row scored zero and the round just ran out. Both snap now. Anything that changes how a shot chooses a body has to touch both — same shape as the "three of five weapons silently no-oped on the racket zombie" bug. - Contact costs the POINT, not the run — a deliberate contract change, hooked in
killPlayer(the single death path for both damage channels, which is why one hook covers every way in). Permadeath stays settled in the gore mode: a memory game where one lapse deletes a thirty-minute run punishes the exact skill it is trying to teach. - The empty-court net in
updateDrill: a live round with nothing bibbed on court means something outside the drill emptied it (seen once while wiring up, not reproducible afterwards). Unrepaired that is a dead mode — no targets, no clock, nothing to tap. It redeals and logsINVARIANT, the same contractwatchdoguses.startDrillalso DISARMS the ordinary schedule (pendingHorde,hordeWarn,bossDue,duelDue): taking the court is not enough if someone else's arming latch is still loaded behind you. - Eight new tests (24 total) pin the ownership contract both ways, the call beat, the clean win, the double fault, the un-winnable-round guard and the softer death.
SECOND PASS, same day — Oliver played it: "the auto aim just mean no skills involved at all. this should just go away.. there is no penalty at the moment.. make some variations of the position on the court and the color". All three landed, and the first two are the same finding: with the shot locked to the called body the mode was unmissable, so the fault could never fire — there was no penalty because there was no way to earn one.
- Both snaps deleted. You aim. What replaced them is one line:
aimTieis 0 during a drill. That is not auto-aim, it is removing the guess — the ordinary magnet lets a nearer body inside the same cone steal a shot aimed past it, which is right for a crowd you are mowing down and exactly wrong where hitting the wrong body IS the failure. An unaimed tap now targets nothing:splatServeTargetreturns null andhitServefalls back to the service box, so the ball lands on grass. Verified end to end in the browser: aiming at a decoy kills the decoy, raises FAULT, and sets the rush; a second wrong body prints DOUBLE FAULT and the round is theirs. - The palette is SIX (purple and white added, two more
say -v Danielrecordings) and the court takes three to five of them (drillBodiesMin/drillBodies). Four bibs out of a four-colour palette meant every round dealt every colour and only the order changed — three quarters of the round was known before the umpire opened his mouth. Now which colours turn up, how many, and how many decoys are all live questions. PLACEMENTSin drill.js — five shapes, weighted, mirrored left/right per round:fan,cluster,column(two or three SHARING A LANE at different depths — the shape that makes feet matter, because the ball hits the first body it touches),stagger,wing. Lanes are shuffled against the bib order, so the called order is unrelated to both the left-right order and the depth order. Measured over 30 deals: all five shapes appear, counts 3/4/5 roughly even, all six colours rotate, and the first called target is screened by another body in about 10% of rounds — often enough to matter, rare enough not to be a tax.- The pips are DICE FACES now (
PIP_FACES, shared by the bib and the HUD chip so they cannot drift). A row of five dots has to be counted; a die is recognised — and the redundant channel is pointless if reading it costs more time than reading the hue. Known limit: the die reads on the HUD chip at any distance but degrades on the BODY as it recedes. The bib colour carries; the face does not. If that turns out to matter, the answer is a bigger bib, not more pips. - Ten tests now (26 total), including a named regression for the auto-aim being gone and one that deals 40 rounds and asserts colours, counts and shapes all move.
THIRD PASS, same day — "the drill mode immediately goes into some horrible audio loop" (phone).
Never reproduced on desktop, and that is the tell: three of the four defects behind it can only
happen on a phone, and my whole verification had been stepping frames with rAF killed on a machine
whose AudioContext was already running. All four are in voiceSeq, which is new code, and every
one of them is a rule voice() had followed for months and my function quietly broke:
- A SUSPENDED AudioContext HAS A FROZEN CLOCK. On a phone the context starts suspended and
currentTimedoes not advance until it resumes. Scheduling four clips atcurrentTime + 0, +0.9, +1.7, +2.4against a frozen clock puts every one of them in the PAST by the time the context actually starts — so they all fire on the same instant, on top of each other, through a 1.3 s reverb tail. Resume FIRST, and re-read the clock after the decode. - A SECOND CALL LAYERED INSTEAD OF REPLACING.
voice()has always enforced one man, one microphone;voiceSeqSET that clock (voiceUntil) and never checked it. Two calls close together = two overlapping lists. It stops the outstanding one now. - CANCELLING A LIST HAS TO GIVE BACK THE TIME IT BOOKED. The first cut of fix 2 stopped the old
sources but left
voiceUntilstanding, so the replacement queued behind a list that would never play — measured at 6.6 s of silence before the call the player is waiting on, and every redeal made it worse.seqUntilBeforerewinds the booking. - THE AMPLIFIER, in drill.js: the empty-court net redealt ON THE SPOT, which makes the repair
rate the FRAME RATE — and every deal calls the umpire. It goes through the ordinary
between-rounds beat now, so the worst case is one redeal every
drillBeatseconds: a mode that limps instead of a phone screaming at you. Pinned by a test that empties the court every frame for six seconds and asserts at most one call per beat.
And a measurement lesson worth more than the fix. The first "it's clean" reading was taken by
counting fetches of assets/audio/* — which proved nothing, because VOICE[] caches every clip
and a repeat play never fetches again. The honest probe tags each decoded buffer with the file it
came from (patch decodeAudioData, pair it with the pending fetch URL) and counts
AudioBufferSourceNode.start — then "did two umpire lines overlap" is a real question with a real
answer. Counting requests measures loading; counting start() measures sound.
FOURTH PASS — THE STREAM. Oliver played it again: "daniel announcing is maximum annoying" · "don't let the rush in position just let them coming from the back in steady speed.. try to space them not too close" · "when one combination is done just let the new one in.. in a continuous stream that increases in speed and difficulty" · "the rounds can stay, they should be continuous".
The ROUND survives as the unit; every SEAM around it is deleted. No walk-on to a mark, no announcement beat, no between-rounds pause, no deal. Bodies walk in from the far baseline forever, and a round is a colour combination drawn from whoever is on court — finish one and the next is drawn in the same frame, off the same rolling population. The bodies you were ignoring a second ago are the ones being called now, which is a better decoy than the old fixed-batch decoy ever was.
- Daniel is gone from this mode. At ~0.8 s a word he needed four seconds to read a list of four,
and with rounds running continuously that is a man talking over the entire game. Each colour has a
PITCH now (
DRILL_COLS.tone) and a round announces itself as a four-note figure in half a second (blip()in audio.js — a plain triangle with a 12 ms attack, because a hard attack clicks).TUNE.drillTones0 makes it silent. Thedrill_*.m4arecordings stay in assets: cheap to keep, and a slower variant of the mode could want them back. PLACEMENTSis deleted along with the marks. Nothing rushes anywhere and nothing stops, so there are no shapes to place — the geometry now comes from lane choice at spawn plus the depth the stream naturally strings bodies out into. That is a net DELETION of the fanciest code in the file.- Spacing is best-of-9 lane sampling against every live drill body (it measured only the back half until 2026-08-16, see that session log), not a retry loop: a hard minimum would silently stall the stream at exactly the moment it is meant to be getting harder, which reads as the mode dying.
- A DRILL BODY HOLDS ITS LANE, and this was the fix the screenshot forced.
updateZombieshomes bodies onto the player's x once they cross the net — a gore-mode behaviour — so they arrived nicely spread and then bunched into an unreadable knot right in front of you. Spawn spacing cannot survive that. Their lane IS the puzzle here (it decides what screens what), so drill bodies walk it straight in with nothing but the wobble. They still reach you; the lane runs past the baseline. - One ramp, off TIME SURVIVED (
TUNE.drillRampT210 s), driving walk speed, spawn gap and round length together. Time, not rounds won: a round-counted ramp cannot escalate while you stand still. Speed is stamped per body AT SPAWN, so the ramp shows up as new arrivals outpacing the ones already walking rather than as the whole court accelerating out from under you. - The empty-court watchdog is gone too, and nothing replaced it. With a stream there is no state
to be stuck in:
if (!d.seq.length) newRound(d)covers the start, a completion, a double fault and "everything named got killed by something else". One line replaced a repair path, a cooldown and a log entry. - Movement is
updateZombies' ordinary forward step now (bodies carry a realzb.v), so the stride animates off distance covered for free — the round-based version drove them by hand because they had marks to stop at. - Tests rewritten around the stream (27): arrivals keep coming and are spaced after the walk in,
lanes are held by identity, the ramp raises speed / arrival rate / round length, a finished round
rolls into the next in the same frame, and being caught costs the point and the court but not the
run. Two test traps worth remembering: a drill test that never shoots WILL be caught (~28 s to
walk the court) and the sweep looks exactly like a stalled stream — use
TUNE.eatenR = 0, the established soak trick; and never comparezombies[i]across frames, because bodies despawn at the baseline and the array shifts (that read as a 1.5 m lane drift that never happened).
FIFTH PASS — two cuts. "what are the dice dots for?" -> "drop them", and "the dodge swipe should not reset to the middle when firing".
- The die-face pips are gone from the bib and the HUD chip. They were colourblind redundancy — a channel so the mode did not rest on hue alone — but they were noise to everyone who can separate the colours, and they shrank to nothing on a body at distance, which is exactly where the channel had to work. This is a known accessibility gap now, recorded rather than solved: if the mode ships, the answer is bibs that differ by SHAPE or marking at sprite scale, not dots. A channel that fails at the far baseline is not a channel.
- The dodge fix is in gameplay.js, not in the drill, because it was never a drill bug — see the new invariant above. It changes the gore mode too, and deliberately.
SIXTH PASS — THE MODE IS WINNABLE, AND THE DANGER MOVED. Oliver: "make the game that it is actually winable meaning that all required colors are visible on the far side before when a color combination is shown.. for now no colors that are not in the current combination but occasional zombies which don't have a color and are converging to the player trying to eat him".
Two rules now define the file, and everything else falls out of them:
- Every colour on court is IN the current combination. No coloured decoys at all. A colour you can see is a colour you will be asked for, and the strip can never name a body that is not there.
- A combination is only shown once its bodies are all on the far side — free, because the BATCH IS BORN THERE. It is a property of the construction rather than a check that could be wrong: a batch spawns at the far baseline, its combination is called off it on the same frame, and the length of the court is the time you are given to work the list.
The pressure moved to the EATERS, which is a better home for it: plain unbibbed shamblers on
their own clock (drillEaterEvery -> drillEaterMin, capped at drillEaterMax on court).
Deliberately NOT flagged drill, so they keep the gore mode's homing and converge on you while the
bibbed batch walks its lanes. Killing one is never a fault (drillKill ignores a body with no bib)
and letting one reach you is the ordinary caught. The puzzle plays fair and the danger arrives on
its own clock — much better than a puzzle that cheats.
The batch is the round: kill the last of it and the next walks in on the same frame with its
combination already called. drillBatch 3 -> drillBatchMax 6 on the ramp, so the list grows AND
the court fills.
One real bug, and the new test is what caught it: killZombie resolved the drill BEFORE splicing
the body. drillKill can draw a new combination on the spot (a double fault re-calls the
survivors; an emptied batch brings in the next one) and it draws from whatever is standing in
zombies — so the new combination could name the body that was in the middle of dying. That is a
round asking for a colour which is not on court: precisely the un-winnable state this pass exists to
make impossible. Splice first, resolve second.
SEVENTH PASS — the colour comes off the body, and the mode stops making noises. Oliver: "the colored zombies should also explode like they did before and make the normal splash sound on destruction.. no bleeps please.. can we make the color as a color circle the walk on? the sign they're holding is not nice. also remove the ball slicing in this mode.. this leads to unwanted hits". Four things, and one of them was a straight bug of mine.
- THE BUG: bibbed bodies were dying silently and cleanly. My hook in
killZombiediddrillKill(zb); return;— and everything below that return is THE DEATH: the blood decal, the pile, the 36-particle burst, the splat and the headshot crack. The drill resolves after the splice and without returning now. Both halves are load-bearing and each was a bug in turn: splice first or a freshly drawn combination can name the corpse; don't return, or the body just vanishes. - The colour is a DISC ON THE COURT that the body walks on, drawn BEFORE the sprite. The bib was
a flat quad pinned to a walking sprite — UI stuck onto a character rather than part of the world,
and it covered the artwork. On the ground it sits in the court's own perspective, it is far larger
in pixels than a chest patch at the same depth (measured 12.4 x 4.2 logical px at the spawn line,
24 px wide by the time it reaches you), and it hides none of the sprite. The risk was reading as
another gore decal; the answer is the RING — gore is a soft flat blob, this has a hard dark rim and
a bright inner line, so it reads as painted court marking.
TUNE.drillRingRsizes it. Weakest case is GREEN on grass — legible thanks to the rim, but it is the one to watch. - NO SLICING IN THE DRILL. A cut ball leaves the corpse and strikes whatever it finds next; in a
mode where hitting the wrong body IS the failure, that is the game choosing your target for you and
choosing it badly. One guard on the
ball.chainbranch. This also retires the old open question about a ball completing two steps of a combination in one shot. - THE MODE NOW HAS NO SOUND OF ITS OWN, deliberately. Two attempts have been vetoed: the recorded
umpire reading the list ("maximum annoying") and the arpeggio that replaced him ("no bleeps
please").
blip()is deleted from audio.js,drillTonesfrom TUNE, the correct-kill tick fromdrillKill. What is left is the game's own vocabulary — a kill bursts and splats like every other kill — and the strip carries the list. Do not add a third noise here without a reason: both attempts read as the mode talking over itself.
EIGHTH PASS — the court gets quieter. "remove the mob boy - make the normal zombies rarer and not so close to the color gang".
sendBoyrefuses during a drill, at the function rather than at the call sites (same shape as its existingG.interludeguard, so every present and future caller inherits it). He is one more body crossing the lanes you are reading colours down, and unlike everything else on that court he is neither the puzzle nor the threat — pure noise. Measured: 0 boy-frames in 90 s. Note this means gore no longer gets mopped in the drill; thegore40 /piles30 caps are what keep it bounded, which they already did.- The eaters are rarer:
drillEaterEvery7 -> 12 s,drillEaterMin2.2 -> 5, cap 4 -> 3. At the old rate they were a second crowd rather than an interruption, and a threat you break off to deal with only works if breaking off is occasional. Measured: 8 arrivals in 90 s where the old numbers gave ~13. - And they enter WIDE of the colour gang — best-of-9 lane sampling against the bibbed bodies, the same trick the batch spacing uses. Measured narrowest clearance at entry across 8 spawns: 0.91 m. They still converge on you afterwards, which is what they are for; this only decides where they come in, so they stop screening the bodies you are trying to read.
NINTH PASS — THE ROSTER IS THE ALPHABET, and it deletes a whole subsystem. Oliver: "we don't need colors at all! we already have different zombie types.. so the sequence is the zombies themselves". He is right, and it is better on every axis the colour version was losing on.
The bib, then the ground disc, were both inventions bolted on to give a body an identity it
already had. A brute does not look like a runner, the game is built on reading those silhouettes
at speed, and the strip can show the body's own walk frame instead of a swatch — no legend, no
mapping, no new art ever. It also deletes the accessibility problem outright instead of
deferring it, because a silhouette is not a hue. DRILL_COLS, drawColourRing, drawBib,
PIP_FACES, blip(), drillRingR, drillTones and the seven drill_*.m4a call recordings are
all now gone or unused — the mode is materially smaller than it was three passes ago.
TWO RULES keep a type unambiguous and both are load-bearing:
- the plain shambler is never in the alphabet — 'a'/'b' is what the EATERS are, so "not part of the puzzle" is legible at a glance and needs no marking on anything;
- one of each type on court at a time, guaranteed by the batch dealing distinct types and by eaters only ever being shamblers. "The brute" always names exactly one body.
The new failure mode this created, and it is a good example of the class. Types move at different speeds, and drill bodies hold their lane — so a body in a lane you are not standing in walks the full court and off the back untouched. Left as the ordinary despawn that stranded the sequence asking for a body which no longer existed: precisely the un-winnable state the mode exists to prevent, reintroduced by a change that looked purely cosmetic. Two fixes, both needed:
- a batch body crossing your baseline counts as CAUGHT ('GOT PAST YOU'), which is also just better — the drill is "don't let them through" and now the lanes mean something;
- the speed spread is narrowed to 1.35..0.70, not the roster's own 3.2x..0.45x, so a batch stays coherent. The types still read as different because their real difference is HP, untouched: a runner is one shot and a roller is seven, so the called order still decides how early you must start on what. Verified: four minutes of simulation, 14400 frames, zero frames in which the sequence named a body that was not on court.
Watch: a roller in the middle of a sequence is seven aimed serves. Oliver asked for the roster's robustness to be kept, so it is — but if a round ever feels like grinding rather than reading, a drill-only HP scale is the knob, not a change to the roster.
TENTH PASS — two switches, and the round-boundary bug.
TUNE.drillOneShot— tried ON for one playtest, now OFF. "one-shot is bad - way too easy - also we're losing all the nice gore animation sprites". The second half is the one that is easy to miss and worth remembering generally: a 1 hp body never passes through a wound stage, so every skin inZWOUND(arm off, ribs out, still walking) is skipped and the roster's best art never renders at all. The HP is also what makes the called ORDER matter — a roller is seven serves, so being told to take it third means starting on it first. Kept as a switch, never a default.- THE ROUND-BOUNDARY CARRY-OVER, and it was a real unfairness. "the last kill of a game can
carry to the spawn of a new zombie of the next round". A ball keeps pace through a kill
(
TUNE.zPierce) and it travels UP-COURT — which is precisely where the next batch is born. So the shot that finished your round flew on into bodies that did not exist when you took it and faulted you on a round you had not been shown yet. The shot that ends a round is spent (ball.active = falsein drillKill's completion branch, and indrillCaughtfor the same reason: that also refills the court up-court). Pierce WITHIN a batch is untouched — that is honest occlusion you could have read; this was not. TUNE.drillEaterMax3 -> 0 (OFF): "remove the fake zombies for now". The drill is the pure puzzle with no second threat at the moment. The mechanism is untouched —spawnEater, the wide entry away from the gang, the never-a-fault rule — so one number brings it back. The test suite asserts whichever contract the knob currently describes (at 0: nothing but the puzzle ever appears; above 0: they arrive, capped, always plain shamblers) rather than being deleted and rotting, and the never-a-fault rule is now tested with a hand-placed body so it holds either way. That is the shape to copy for any other switchable subsystem here.
ELEVENTH PASS — TRIAGE: the drill finally gets a skill. Oliver's verdict on the sequence version was the honest one — "it feels not interesting enough to keep me playing.. I am really looking for some mechanic to make this skill based" — and the diagnosis was that it tested nothing: the strip was always visible (no memory), the batch was slow (no urgency), the aim was easy (no execution). A checklist with gore. Settled in discussion: options were hide-the-call (memory), triage (judgment), clock+streak (score); he picked triage + streak.
ONE RULE: always kill the body that would reach you first. Nothing is called, nothing shown —
the court is the puzzle. arrival(zb) = (COURT.L - zb.z) / zb.v down a HELD lane (that is why
drill bodies hold lanes — it makes the projection honest), and the roster's speed spread means the
answer is not the closest body: a runner at the far baseline beats a roller at the service line.
This is court sense, the most tennis-flavoured skill there is, and it trains the exact
target-priority read the gore mode rewards. The strip, the alphabet, the batch and the eaters are
all deleted — shamblers are first-class stream members now, pts 20 up to a roller's 100.
The three design points that make it a game rather than a quiz:
- WOUNDS ARE FREE. Only the KILL is judged (drillKill sits behind the kill funnel, wound()
never reaches it), so pre-softening a heavy while it is third in line is legal and eventually
necessary. This is also why
drillOneShotmust stay off: 1 hp bodies delete the planning layer. - THE SLACK (
drillSlack0.9 s): two bodies arriving within it are both legal. Without it a near-tie made the rule a coin flip and a fault read as theft. Tightening it is the difficulty curve for good players. - THE STREAK IS BOTH PUNISHMENT AND PAYOUT. x1..x5 (
drillMultEvery4,drillMultCap5), a misread resets it and shows OUT OF TURN, and nothing else happens — no sweep, no lost round; the queue keeps walking. Being CAUGHT is the real setback (sweep + ramp handback), score survives. Best is banked in localStorage (drillBest); the world board can carry it if the mode ships.
TUNE.drillMark (ON for the playtest): a chevron over the queue head. It exists so Oliver can
check the game's read against his own — and it answers the exact question the mode asks, so it MUST
be off in anything shipped. If his read and the marker disagree often, tune drillSlack before
touching arrival.
TWELFTH PASS — the marker stops lying, and the rule announces itself. Oliver: "the marker flips all the time even during a ball being midair.. i am not sure that a player would get the mechanic intuitively let alone without the marker".
The flip had a specific cause and it was a design bug, not jitter: with lanes held and speeds
constant, arrival DIFFERENCES never change while bodies walk — the queue only reorders on a kill or
an arrival. So the marker was flipping because it showed ONLY q[0] while the rule accepts any
near-tie within the slack: every kill made it jump between tied bodies, i.e. the marker was
stricter than the rule it was explaining. drillLegal() now marks the whole slack set — bright
head, dim ties — which is stable by construction. Measured: zero head-changes across 12 s of
walking with no kills.
Learnability, two answers:
- the rule gets ONE modal, once per run, on the mode's own calm court (the seeded bodies are all at the far baseline — this is the popup policy's sanctioned case): CLEARANCE DRILL — DROP WHOEVER WOULD REACH YOU FIRST · FAST BEATS CLOSE · STREAKS PAY. "Most urgent first" is not guessable from a chevron alone; one sentence makes it learnable in the first five seconds.
- the markers are training wheels that take themselves off: they hide at streak >=
drillMarkOff(8) — the streak IS the evidence you can read the court — and any fault brings them back. The teacher steps in exactly when you demonstrably need it and out when you don't, anddrillMark: 0still kills them outright.
THIRTEENTH PASS — ZOMBIE TENNIS, and the drill steps aside. Oliver on triage: "i think the normal player does not grasp this" — the lesson across four drill designs being that every invented rule needs teaching, and the only rules that don't are the ones the scene already implies. Brainstormed again; he picked the dark horse: real tennis against the dead. One game per opponent, blood only. "i can live simple tapping and timing" — the tap-and-timing scheme, not the slingshot.
MATCH TEST in the back room (#match). Built ON TOP of the duel machinery, deliberately:
G.match runs a PERMANENT interlude — which is already what makes serving tennis-like, hides the
rack, quiets the spawn director and wakes racketAI. The one change is in resolveInterlude: a
settled point feeds matchPoint instead of ending anything. ~90 lines in beats.js for a whole mode,
because the deepest system in the repo (swings, volleys, lanes, the AI) was already built.
- The score IS the body: a quarter of the pool per point won, floored at 1 — a love game walks
the racket zombie through every wound stage and the game point fells it (
explodeat the body). The scoreboard and the corpse agree all the way down.matchOppHp4 + 2 per opponent felled. - Its points draw your blood:
G.hp--, eaten at zero, the ordinary channel. Four clean points against you IS the run —matchHeal2 per game won is how you last. - Regicide inherited free: smashing the ball into the body still wounds it, killing it outright wins the game early (killZombie -> resolveInterlude('P') lands with the court empty; the game is banked BY HAND there, since advancePoint never saw a game point).
- THE TIMING IS DRAWN — his ask: "what is the possible tap window what is the sweet spot".
Two cues riding the mechanics' own numbers so the picture cannot lie: the APPROACH RING (a ring
around the incoming ball that kisses a fixed inner ring exactly at
matchRingSweet2.7 m — the centre of the tapped-points benchmark's window — gold insidematchRingBand), and the APEX BAND (a guide beside the toss, gold across serveApexY ± the same 0.12 m keyPressed scores as perfect, a white tick riding the live ball). Match-only for now; they would serve plain tennis and the duel too if Oliver wants them there. - Verified by autopilot in the browser: 7 games in 90 simulated seconds, opponents 1..8, pools 4/6/8/10, wounds tracking points exactly, heal capping at hpMax.
- One harness trap that cost a round: startMatch queues TWO first-sight modals (EXHIBITION
MATCH, then the racket zombie's own) — and a dismiss during a modal's entrance (
m.in < 1) is swallowed BY DESIGN. A scripted playtest must clearG.modalANDG.modalQ, or the world is frozen and sixty seconds of stepping measures nothing.
The drill stays in the back room beside it — triage is a decent idea that failed the grasp-in-five-seconds test, and the match is now the lead candidate for the middle mode.
FOURTEENTH PASS — the match plays fair. Oliver: "the player is too weak in this mode. also the killing by smashing occurs too often. i cant really control it. maybe obsolete? also the serving after the first serve does not happen from behind the groundline".
matchPlayerHp8 — the match brings its own pool. The gore mode's 4 was never a decision here, just an inheritance, and a real game has rallies you deserve to lose without the run ending.- REGICIDE IS OUT OF THE MATCH (kept in the duel, where "any weapon counts" is the point of a
sudden-death). In a full game, every hard serve that happened to meet the body was a free kill
nobody aimed. A match ball NEVER damages the opponent now — it passes through like a soft ball and
racketAI plays it — so the wound stages are exactly the scoreboard with no side-channel smearing
them. This also deleted the hp-per-opponent escalation (a deeper pool changes nothing when the
body is pure scoreboard): the ladder escalates in racketAI instead —
matchSpeedPer(+12% speed per body felled) andmatchFlubKeep(whiff chance × 0.82 per rung). - Serving resets to BEHIND THE BASELINE with real deuce/ad mirroring —
setupPoint's splatter branch ("serve from wherever you're standing") now excludes the match, which falls through to the tennis branch. Serving from mid-court where the last rally left you read as a bug because it was one. Verified: second serve from z 24.3, x mirrored to the ad side.
FIFTEENTH PASS — the opponent shows up, and the death loop dies. Oliver: "still very weak and slow opponent. it also spawns at places where it is impossible to react to my serve. so i win like always. in the cases i dont win there is a bug. when trying to serve after i got hurt i get hurt again and again until i die".
- THE DEATH LOOP was
G.serveNum. Tennis'spointTois the only thing that ever reset it — and the match bypasses pointTo entirely. So the first fault of the match armed "second serve" PERMANENTLY: every missed serve after it was a double fault -> a point -> blood -> repeat to death, which is exactly the spiral a just-hurt, rattled player serves into.matchReady()(the match's own point boundary) resets it now, and a regression pins it. - The receiver starts every point ON ITS MARK. It was spawning at a random x and standing
wherever the last rally abandoned it — an opponent that cannot reach the serve is not weak, it is
absent, and every ace against it was hollow.
matchReadydrops it onto the exact receiver position the tennis branch computes for O (deuce/ad mirrored with the server). - The base opponent is a player now:
matchSpeedBase1.4 (the duel's 2.8 m/s lumber was tuned for ONE sudden-death point, not a game) andmatchFlub0.08 (the duel's 0.2 gives away a fifth of all returns — an opponent that beats itself). Measured with the same autopilot that previously won seven straight games: it now loses the match (2-3, then blooded out in game two). That may even be too hot —matchSpeedBase/matchFlubare the two dials, and Oliver's thumb is the judge.
SIXTEENTH PASS — the REAL death loop, and my serveNum fix was a red herring. Oliver, again: "still when I lost a single point and try to serve again every further tap does hurt me more until I am dead". Reproduced tap-for-tap this time before touching anything, and the previous diagnosis was wrong — serveNum was A bug, but not THE bug.
tossBall never stamped ball.lastHit. After any rally the zombie touched last, the freshly
tossed ball still said lastHit: 'O' — and the fourteenth pass moved the server BEHIND the baseline
(z 24.3, past the passing-shot line at COURT.L + 0.3). So the moment the toss went up, the game saw
"an opponent ball beyond your baseline": its passing shot ends the duel -> point to them -> blood.
Every tap = a toss = an instant lost point, faster the more he panicked. Two of my fixes composed
into a death loop: the stale field was harmless while serving happened INSIDE the court.
One stamp in tossBall (ball.lastHit = sv.side — the toss is the server's ball) fixes every
caller. The duel and the gore mode carried the same latent bug for anyone tossing from deep
behind the line after a racket-zombie touch; it never fired only because splatter serving used to
happen from inside the court. Verified in the browser with the exact field shape: lose a rally the
zombie touched last, then eight badly-timed panic taps — one honestly lost point, zero toss-bleeds.
The lesson for this file: when a report survives a fix, the fix was wrong — reproduce before re-diagnosing. The serveNum reset was real and stays, but it was found by reading code, not by reproducing the report, and the actual loop needed the exact field conditions (a zombie touch, the new serve position, panic-speed taps) to show itself.
SEVENTEENTH PASS — "every second ball is unreachable" was the player, not the CPU. Oliver:
"the opponent is really hard to beat now.. every second ball is basically unreachable by an
auto-run player.. are there some hidden gestures I don't know about or is it just a very strong
cpu?" Neither: the match had inherited gore-splatter's depth-only recovery (the fifteenth-pass
sidestep-protection rule) by virtue of being G.mode 'splatter' — so one wide return pulled you off
centre and you STAYED there, and the next ball to the opposite corner was lost before it was struck.
A player who never walks back to centre experiences every rally as "unreachable"; it read as CPU
strength because the balls kept landing where he wasn't.
- The match recovers to CENTRE now, like tennis — real footwork, and in a rally there is no artillery lane to protect. Gore splatter keeps the depth-only rule; the sidestep it protects has no role inside a rally.
matchSpeedBase1.4 -> 1.25: it was calibrated against the broken recovery, i.e. against a player who couldn't move. With footwork restored the ladder starts gentler and still climbs.- For the record, the full attacking vocabulary — nothing else is hidden: timing quality (the ring
and the band), PLACEMENT by flicking/holding a direction during the swing (
doHit's aim — deep, short, wide; wrong-footing is how points are won, the AI recovers slowly on purpose), VOLLEYS (stepping in to take the ball pre-bounce carries reach x1.4 / window x1.8), and serve pace off the apex. The placement flick is taught nowhere on touch — worth a ghost-hand lesson if the mode ships.
EIGHTEENTH PASS — the flick shows itself, the width is a dial, the blood stays on.
- THE PLACEMENT RING: an aimed rally hit (flick or held direction at contact) stamps
G.placeMarkand a gold ring fades where the shot is going — flick, watch it bite the corner, learn the cause. Deliberately NOT stamped for unaimed hits: marking the scatter would teach noise. Fades in 0.8 s; a permanent ring would read as a target to hit rather than feedback. matchWideBase2.1 /matchWidePer0.4 — the opponent's WIDTH is the ladder's third dial. Its returns scatter offaimError x (1 - quality), which painted lines from game one ("still a LOT of unreachable balls"). Opponent one now keeps its returns inside what an auto-run player can actually cover (measured: 25 returns, all inside ±2.2 m), and earns 0.4 m more width per rung — line-painting is the LATE ladder. Speed, reliability, width: three dials, all per-rung.- THE BLOOD VIGNETTE replaces the temporary border. The old edge wash flashed 0.3 s and
vanished — between hits the screen said you were fine. Now a radial vignette reads
G.hpdirectly: its depth IS your damage fraction, permanent until healed, and the hit pulse rides on top. Global (the hp channel is global), not match-only. Same no-HP-bar philosophy, one layer out: the state is worn, not reported. At 1 hp the frame is properly blood-rimmed and the court centre stays legible.
NINETEENTH PASS — pace was the missing dial, and the reachability test finally exists. Oliver: "the vignette can get more pronounced and there are still balls which are basically not reachable with auto run.. please recheck" — plus, asked twice and deserving a straight answer: "did you write tests for the last issues we encountered?" No — the unreachable-balls issue only ever had config assertions, which is exactly why it came back twice.
- Pace, the ladder's FOURTH dial (
matchOppPace0.78,matchOppPacePer0.05/rung): width alone never fixed reachability because the returns left at ~27 m/s — crossing to your baseline in ~0.6 s, less than auto-run needs from the far corner. Opponent one now returns at ~20 m/s (measured live: twelve returns, 20.0-20.7), a quarter more flight time; the rungs take it back. - The vignette deepens:
dmg * 0.8(was 0.5), cap 0.85, and the clean centre SHRINKS with damage rather than only the rim darkening. - The REACHABILITY REGRESSION (the test this issue always deserved): a headless 90 s autopilot soak — auto-run, fixed tap window, no placement — asserting ≥10 returns sampled and ≤15% that never came within volley reach. Sabotage-verified: cranked knobs trip the suite. Its honest limit, recorded so nobody overtrusts it: the autopilot attends perfectly and taps frame-precisely, so it OUT-COVERS a human — knob-legal-but-hostile values still pass it. It is a floor against catastrophic regressions, not a proxy for Oliver's thumb; his playtest stays the real gate for "reachable".
TWENTIETH PASS — the flight recorder, and the statue with a racket. Oliver asked to be WATCHED:
"can you watch me play in the browser pane and see why I loose". A passive 20 Hz recorder was
injected (samples score/ball/player/opponent, keeps the last 1.5 s of trajectory before every lost
point, touches nothing) and he played a real match in the pane. Seven losses on record, and the
verdict was unambiguous — he swung in every single lost point (4-5 swing frames each) while the
ball died 2-3 m to his side, and his own x never moved all game. His timing was fine. He had no
legs: autoAssist keys off pointer: coarse, the pane reports a FINE pointer, so auto-run was 0 —
the exact trap the verification recipe has warned about for weeks, now bitten by the designer
himself in live play.
matchAssist1.15: the match brings its own legs on every device. It is a tap-timing game; footwork is the game's job. Held keys still override (the assist only runs with no manual input). Pinned by a test that plays withautoAssist0 — the desktop condition — and asserts he runs.- The vignette curve is sqrt(damage), not linear: the match pool is 8, so hit one was a 10% alpha nobody can see ("basically invisible"). sqrt front-loads it — first blood reads at ~0.3 and it still deepens to the cap.
- The flight-recorder pattern is worth keeping: instrument passively, let Oliver play, read the trails. It ended a three-round guessing match in one session — his reports and my autopilot measurements kept disagreeing because the autopilot had legs he did not.
TWENTY-FIRST PASS — the short ball, the home-bound intercept, and the portrait vignette. Second recorded session: 5 wins and a deuce war to 5-6 (the mode is a contest now), but all seven losses shared one signature — the ball DIED IN COURT 1.4-2 m short of him while he ran 0.2-0.5 m. Plus his note "there seems to be a problem when the ball arrives right from the player" and "still no vignette. just a slight red pulsing from below". Three distinct defects:
- The pace cut created DYING ROLLERS: slow returns bounce and die at z 18-20 — outside
intercept's near zone, so nothing ever chased them, and the rolled-dead rule gives the point to the last hitter (the zombie). They also arrive at his feet below legal contact height, which is the "ball arrives right at the player" feel.interceptnow keeps ananyHitcandidate — the earliest REACHABLE point on his half where the ball is still at hitting height — so the assist runs FORWARD onto the short ball, which is the tennis answer. (Zone points still win: deep is better.) Verified: the chase test shows him sprinting z 23 -> 16.4 and meeting the ball at gap ~0. interceptcomputed reachability withautoAssist— 0 on any fine pointer — so a desktop match's intercept thought nothing was reachable and answered "home" every time: he had legs (matchAssist) but his destination was always the baseline centre.assistK()is the shared helper now; both the mover and the planner use it.- THE VIGNETTE WAS RADIAL IN A PORTRAIT CANVAS. The circle reaches the corners but arrives at the left/right screen edges at fraction ZERO — "red pulsing from below" was the bottom edge being the only place the circle got close. It is FOUR LINEAR EDGE FADES now, hugging the actual rectangle on any aspect, sqrt(dmg) curve kept, inset deepening with damage. Posed at 6/8 hp: all four edges carry visible red.
- Test-measurement trap for the log: reading
P.zafter the chase loop proves nothing — the point reset teleports him home. Measure the minimum DURING the chase.
TWENTY-SECOND PASS — the longer racket. Third recorded session, and the previous fixes did
their job while exposing the last gap: he ran onto every ball (2.9-3.9 m per point, was 0.2-0.5),
the ball reached him (died at z 22.5-24, was 18-20), he swung every single time — and the closest
approach clustered at a sampled 1.3-1.6 m, centimetres outside the 1.3 m post-bounce reach, point
after point. The assist can deliver you NEAR the ball but not inside a phone-tuned contact radius.
matchReach 1.35 widens the contact test in the match only (gore modes untouched — their "tight
contact radius: the tap must be honest" rule stands). A rally mode lives on contact feeling fair.
Note for future recorder sessions: a 20 Hz sampler overstates closest approach by up to ~0.5 m
on a 20 m/s ball — the recorder now samples at 60 Hz.
TWENTY-THIRD PASS — first game won, and the rungs flatten. Fourth recorded session, his
verdict "better": he beat opponent one — the first game won in any recorded session — and reached
opponent two, which then took him apart with gaps of 1.66-2.13 m (60 Hz, accurate) while he swung
at closest approach every time. The ladder gradient is real; the STEP was a wall, because every
rung moves three dials at once (speed, width, pace, plus flub). Per-rung increments softened:
matchSpeedPer .12->.08, matchWidePer .4->.25, matchOppPacePer .05->.035. Opponent one is
untouched — the bases stay; only the slope flattens.
The contact chain is now verified end to end by recorded play: LEGS (matchAssist) -> DESTINATION (assistK in intercept + the anyHit short-ball chase) -> REACH (matchReach). Each link was a separate defect found by a separate recorded session. The flight recorder earned a permanent place in the toolbox: passive 20-60 Hz sampling with pre-loss trails ended four rounds of "still the same" in four sessions, where autopilot measurement had failed for three passes straight.
Open, and every one of them is Oliver's call after he plays it:
A ball that carries through the lane can complete two steps at once.Largely CLOSED by the slice removal (seventh pass) — a cut ball picking its own next victim was the main way this happened. A straight ball can still plow through a body standing directly behind another (TUNE.zPierce), which is honest occlusion rather than the game aiming for you. Watch it.The tap aims itself.CLOSED by the second pass above — the snap is gone, you aim.Difficulty climbs on sequence LENGTH.CLOSED by the stream — one ramp on time survived now drives walk speed, arrival rate and round length together. What is still NOT ramped is how often the called order fights the geometry; with lanes held, that is a question of which bodies get called rather than of where they stand.- Nothing is wired to tennis scoring, the menu or the run, and the mode has no ending. That is the next job IF the answer to "tense or noisy?" comes back tense.
- A shambler takes two splat-quality hits. In a mode where every shot is aimed by hand that is two aimed serves per body, which paces a round more than the order does. Worth checking on feel whether drill bodies should be one-shot glass.
Session log: beats.js, the rename, and the build gate (2026-08-11, same day)
Oliver's structural question ("framework? smaller files?") answered with a deliberate NO on both — the freezes were never a file-size problem, they were a shared-state interleaving problem — and three targeted moves instead:
beats.js: every mode transition extracted from gameplay.js into one file with one stated rule (one mode owns the court; everystart*enforces it itself). Pure move, zero behavior change, verified by the suite passing untouched before and after.- This file renamed
HANDOFF.md→PROJECT.mdand audited against the code: the duplicated swish-cooldown invariant deduped, the stale umpire-triple-tap comment in gameplay.js corrected (the cheat was removed when the testteam found it), allHANDOFFreferences in code comments updated.T.step/T.press,machEvery4,bossEvery10 all verified still true. - The Dockerfile gained a test stage (
node:22-alpine,RUN node test.js): a red suite fails the image build, so Coolify never deploys a build whose state machine is broken. The final stage COPYs the stage's marker file so BuildKit cannot skip it. - The suite grew five regressions mined from this file's own history: the pending-horde-onto-boss-
stage latch (
7803c83), the game-30 EPIC-card clobber, the mgT cross-phase serve lock (e75b388), the duel's payout+heal contract, and the lost-duel set banking with its surge. CLAUDE.mdadded (auto-loaded every session): the five rules that keep sessions from getting sloppy — read this file first, run the tests, bump?v=, the Dockerfile COPY rule, no deps.
Session log: the third freeze — court ownership enforced, and the first tests (2026-08-11)
Oliver hit the horde freeze a THIRD time, earlier in a run: a sudden-death duel started inside a
live horde. Welded on the right side, no drag (duel's pretoss kills steering), no serve
(G.horde blocks taps), spawns kept coming. The full chain: kills still score during the SET card
beat (spawn director keeps running there), a game won mid-card re-armed G.clearing/G.afterClear
while pendingHorde was already latched → horde started with a clear parked → mid-horde the gun
mowed the court to an empty frame between spawns → G.clearing && !blockers advanced the beat
mid-horde → duelDue fired startDuel(). The watchdog was blind to it by design: its signature
includes horde.t, which ticks every frame, so the state always looked alive.
Fixed at the chokepoints, not the call sites:
update()'s hand-clear advance now requires!G.horde— a latched clear waits forendHorde.startDuel()andstartHorde()hard-refuse while any other mode owns the court (interlude/horde/bossFight). All present and future callers inherit this.splatPoint()is a no-op during the duel —resolveInterludeis the duel's only exit. (A machine or leftover killed mid-duel used to bank score and could re-enterproceedAfterClearmid-duel.)- Invariant net in
watchdog(): if horde+interlude ever coexist anyway, or the phase leavesrallyduring a horde, it repairs on the spot and logsINVARIANTintoG.missLog.
The game now has tests: node test.js — headless Node, real game modules, stubbed platform
(DOM/WebAudio/storage). Seven cases pin the ownership contract, including a scripted repro of the
field freeze and the injected-corruption repair. Run them before touching gameplay.js beats.
Session log: the testgroup round — popups, ghost hand, barrel economy (2026-08-08)
First round of feedback from the small test group (via Oliver), seven items, all landed.
The popup policy changed: a modal is for CALM courts only. The report was "popups block interaction while the game loop continues and the zombies arrive". Measured first, per the meta-lesson: the world genuinely FREEZES behind a modal (a shambler moved 0.000 m over 3 s with one up — the invariant holds). What kills is the CONTROL steal: a popup lands mid-crowd, eats the next tap, and hands the court back with the player's rhythm gone. So every announcement that can land on a live court is a CARD now (non-blocking): the weapon first-sight modals are deleted outright (deliverTin's card and popBarrel's new card carry the one desc line that matters), and the barrel/machine/racket introductions are first-sight cards. The horde popup shows as a modal ONCE per run (it changes the control mode) and every later set starts on the flash alone — re-teaching the same rules at the hottest moment of every set was the worst offender. Still modals: sudden death, the chapter boss, WOUNDS TREATED, test mode — all fire on guaranteed-calm courts.
The transition howto modal is gone; a GHOST HAND teaches instead. "Nobody seems to read this."
drawGhostHand (render.js) is a drawn pointing hand — canvas paths, UI palette, deliberately not a
sprite — with drawTapRing for the contact ripple that makes a mimed tap read as a tap. The apron
demo's old thumb-dot now rides its strokes, and the transition primes G.idleHintT so the pull
demo starts miming the moment splatter begins. TENNIS got three lessons on the same hand, each
keyed to the exact failure it answers and reset by doing the thing: idle in the serve stance →
tap demo on the apron (drawTapHint); G.tossDrops ≥ 2 → the hand rides the toss and PRESSES AT
THE APEX (drawApexHint — the timing is the lesson); G.passStreak ≥ 2 untouched passes → the
hand presses as the ball closes (drawHitHint).
The opening CPU is beatable in a game or two now. Testers ground for rounds. aiFlub 0.09 →
0.18, aiQualityMax 0.85 → 0.72, plus a rubber band: every game the CPU takes adds aiFlubLoss
(0.08) to its whiff chance. Tennis mode only exists before the first transition, so these are
purely the opening-match dials (racket* covers the duels). The old "CPU returns ~91.7%"
benchmark is deliberately retired by this — the expected first-swing return rate is now ~82%
at 0–0 and falls as the CPU loses games.
A shot barrel no longer yanks the new weapon into your hand. popBarrel restores G.weapon
after grantWeapon and pulses the new slot instead (G.rackGlow, drawn in drawRack) — "the
current weapon should stay active, the new slot should glow briefly". The duel's deliverTin
still auto-equips; that one lands on a calm court and IS the prize.
Switching weapons clicks (assets/audio/weapon-click2.mp3, 0.45 gain — sfx() now accepts
names carrying their own extension) and the live rack tile is unmistakable: brighter well,
2 px gold frame.
Barrels ramp with the run (barrelRamp): the gap between barrels shrinks by one every 8
finished games, floored at every game (g0–7 every 3rd, g8–15 every 2nd, g16+ every game). The
late game was "fast tapping until your thumb gives out" once the tins capped; the rack is the
strategic answer, so the run keeps feeding it.
The umpire triple-tap cheat is deleted — the test group found it. T.forceZombies() remains.
Third batch, same day — REACTIONS LEFT THE BAND (option C, settled in discussion). Oliver's
"reactions play after shooting, not when the ball hits" was MEASURED and real: praise() fired in
hitServe at RELEASE while the ball flew for another half second. The word is now ARMED on
ball.praise and pops at the IMPACT — first body the ball touches (zombies.js contact branch) or
its first bounce (onBounce); a rally hit pops at the racket, where contact and praise are the same
moment. Verified: a 32-frame flight popped its word on frame 32.
Reactions are world-anchored pop texts now (pops/popText in zombies.js, drawn in the world
pass before the un-shake): DOUBLE/TRIPLE KILL and MASSACRE rise off the corpse, RETURNED off the
parry, praise off the impact. Small (Bangers 13), rise-and-fade in 0.85 s, capped at 10.
The band got ROWS: flash line 68..90, countdown under it, and bandTop floors a card at 96 so
a tall icon card can no longer print over the flash — that collision was the "spamming over each
other" report. Cuts, per Oliver: the GAME N flash is deleted (with the whole pendingGame
latch — the scoreboard bar prints the number permanently) and the CHANGEOVER flash is folded into
the countdown panel ('CHANGEOVER · RESUMING IN n'). He kept the burst-arm weapon label and the
praise words. A bottom/above-rack message slot was proposed and REJECTED: that is thumb
territory — covered by the hand mid-pull and outside where the eyes are.
Sixth batch, same day — the live card-tuning session (Oliver in the preview pane). Settled by
looking, together: the card is CARD_PAD 20 with the icon boxed to 46×44 and CARD_TOP 88 (flush
under the flash row). And the RESUMING countdown left the band entirely: Bangers 16, gold,
shadowed, floating just above the net tape — no panel, no border. His call: the countdown
belongs to the court the pause is holding, and the shout face survives any backdrop, which retires
the panel that was added for clay-crowd readability two batches ago. The changeover still names
itself in the same line ('CHANGEOVER · RESUMING IN n').
Fifth batch, same day — THE BATTERY PASS. "Drains battery like there is no tomorrow." Measured first: update() 0.01 ms, render() 0.15 ms per frame — the JS is nothing. The bill is fill-rate × frame-rate (a 3x iPhone pushes a ~1080×2400 canvas through the compositor 60×/s) plus 60 Hz wakeups on static screens. Three levers shipped:
- The canvas is OPAQUE now (
dom.js:getContext('2d', {alpha:false, desynchronized:true})) — render() clears the whole frame to a solid colour anyway, so the compositor no longer alpha- blends 2.5 Mpx sixty times a second. Free. - Calm states run slow (
calmFps()in main.js): a SETTLED modal (world frozen, only the blink moves) runs at 20 fps, the death summary once faded at 24. Live play stays at TUNE.fps — the 60 was a feel decision and the governor deliberately never touches anything that moves. TUNE.lowPower(>= 0.5, T-panel-flippable live): caps RES at 3 — −44% of pixels painted per frame, THE big lever.RESisexport letnow, re-derived in fitViewport; main.js re-fits when the knob flips. Sprites are 224px sources so RES 3 draws them at-or-below 1:1: softer, never mushy. DEFAULT ON since 2026-08-08 — Oliver judged it "looks fine" on his phone. SetlowPower: 0for the full-fat render; if it ever becomes player-facing it should be a settings toggle, not a TUNE knob.
Fourth batch, same day — the card went HORIZONTAL, and nothing draws under a modal. The
stacked icon card ran 102 px tall and its lower third hung over live court; the icon sits LEFT of
the text now (boxed to 58×52 so a wide tin cannot reach the type — and it draws BIGGER than the
stacked layout gave it), so every card is a flat CARD_H 62 px at fixed CARD_TOP 96, fully over
the crowd. bandTop() is gone with it. And a modal owns the whole screen now: the flash and the
card are neither DRAWN under its dim nor TICKED behind the freeze (the card tick moved below the
modal early-return, next to the flash's) — a card raised in the same beat as a popup used to burn
its whole life dimmed-out underneath it, and now it holds and plays after the dismissal.
Second batch, same day — THE MESSAGE BAND. Oliver: transient messages need "a definite space
… over the crowd". AD_TOP()/bandTop(h) in render.js pin the whole family there — below the
scoreboard block, above the ad strip, never on the court:
- drawCard anchors in the band instead of centre court (a card at H/2 covered the very play it announced, non-blocking or not) and its title dropped B(30) → B(22) ("the heading is a little too big"). FLASH_Y (68) already lived in the band and is unchanged.
- RESUMING IN n sits on its own PANEL (the info-bar style) under the flash slot — bare shadowed text was unreadable over the sunlit clay crowd, a complaint Oliver had been carrying "forever". It YIELDS to a live card: both draw in the band, and the card is the announcement of the pause.
- The gesture-teaching strings are deleted from the METER_Y slot ('PULL DOWN TO AIM…', 'TAP TO TOSS, TAP TO SERVE', 'PULL DOWN: FURTHER = HARDER') — the ghost hand is the teacher, and the same lesson in two registers was clutter. The slot now only carries STATE the mime cannot: which weapon the serve fires, the gun-unload line, SECOND SERVE.
- Ghost hand v2: 1.4x default scale, hint type F(8) → F(10), and a real silhouette — extended index finger with a nail hint, three curled-knuckle bumps, thumb out to the side. The first cut "could be recognized, but…"; the knuckle bumps are what flipped it from mitten to pointing hand.
Session log: the sidestep, and two type registers (2026-08-03)
Oliver's post-ship playtest. One blocker and seven polish items; all eight landed.
The blocker: he died at every chapter boss because he could not move. Two independent causes,
and fixing either alone would have left him rooted. The movement block is rally-only (a settled
decision the six boss AI styles were written around), and a boss fight is spent in pretoss. On top
of that canAimServe() is true through a boss fight, so the one finger was already spent on the
slingshot — there was no gesture left over that could mean "move". The answer is a sidestep,
not a walk, so the settled decision stands; and the gesture is picked by the DIRECTION the drag
leaves the deadzone in, so nothing about "one gesture means one thing" bends. Both are in the
settled-decisions list above.
Two guards make the dodge worth spending: hurtPlayer (the ranged channel) and the contact test in
updateZombies (the instant-death channel). Verified by pinning a shambler on the player: alive at
dodgeI 0.3, eaten the frame it hit 0.
The message system was seven unrelated looks; it is two registers now. Bangers is bundled
alongside Silkscreen and the split is documented above. drawCard, drawModal's title, G.flash
and the death summary shout; the control hints, the pull/dodge demos, the scoreboard and
RESUMING IN stay quiet. The types Oliver had not counted, for the record: the bottom control-hint
slot at METER_Y, the drawPullHint apron demo, the death summary, and the scoreboard/rack chrome.
Modals queue now. showModal used to drop a second popup opened in the same beat, which is
why the trainer and the weapon had to be crammed into one card after a won duel. G.modalQ is four
lines and it retires that hack: the weapon modal, then a real WOUNDS TREATED modal with the
x_cross first-aid tin over it (the sprite had been preloaded and drawn by nothing since the merge).
One popup, one rule. weaponModal's first-sight gate moved INSIDE the function. The barrels
gated themselves and the tin ladder did not, so four weapons re-explained themselves every three
games for a whole run while a barrel weapon explained itself once. deliverTin gained the label
flash a barrel already had, or the fifth delivery onward would pay out in silence.
Ten rack slots, second row on demand. RACK_MAX 4 → 9. Ten tiles across 480px would be ~39 CSS
px on a phone, under the thumb target rackHit was widened for, so past five it grows a second row
UPWARD and slots 0-4 stay welded to the bottom edge. METER_Y derives from rackTop() now, so the
hint line and the apron demo lift with it.
The apron demo alternates. demoSwipe(dx, dy, label) is the old drawPullHint body with the
direction pulled out; drawPullHint alternates PULL DOWN and SWIPE TO DODGE on the 1.9s cycle, and
mimes away from whichever edge the player is standing against.
The transition punches in. G.zoom is a flat 2D scale about a projected world point, applied in
the same place and for the same reason as G.shake — a perspective dolly would want camZ,
aimCam and every projected HUD anchor to agree for the sake of one 1.15s beat. Escalating shake at
3.4 / 4.8 / 8.2 s.
And the boss win holds. TUNE.bossWinT (3.6s) is its own number so the routine SET card does not
drag with it. EPIC! in Bangers, chapter and crowd demoted to subs, and no GAME n NEXT line —
the bar under the scoreboard already prints the game number permanently.
Session log: the second pass on the barrel weapons (2026-08-03)
The auto-fire jog is gone, and it was one guard. gameplay skipped the pose update in mgSteer
mode (if (!mgSteer && ...) P.pose = moving ? 'run' : 'idle'), so the pose stuck on 'idle' — and
render covered for it by faking a run cycle off G.idleT, which animated whether he was moving or
not. That fake is what Oliver saw: "this hectic run animation... looked odd all the time." The pose
follows MOVEMENT in every mode now and the fake is deleted, so auto-fire (and the horde, and the
melee racket) uses the ordinary run animation and standing still is standing still. The comment that
justified the fake — "the idle sprite has a racket at his hip AND drawMgRacket puts one in his hands,
two rackets" — was solving a smaller problem than it caused; at sprite scale the overlap does not read.
The laser, second pass. Four things, all of them "it must read as light he fired":
- It draws AFTER the net and every body. It was drawn before the far-side pass, so the net and the crowd painted over it — which is precisely why it read as lying on the floor and being under the net. Light in the air goes last.
beamLen26 -> 34. At 26 it stopped a metre past the far baseline and read as being CUT OFF in mid-court. It now carries into the courtside ads, which is where light should stop.beamW00.35 -> 0.06 — a POINT at the strings. At 0.35 it left the racket as a 0.7 m bar hanging in mid-air rather than as something emitted.- It DELETES ordinary bodies, hp irrelevant (
killZombie, not a wound) — a plane of light that left a roller man standing made it a worse ball, not a weapon. A BOSS is the exception and takesdmgBeam8: "they also must suffer severely" but they survive. - The look is four nested wedges composited
lighter— violet bloom, the weapon's blue, cyan, white core — each fading along its length. One quad with a gradient was a coloured shape; layers sharing an axis and brightening toward the centre is what reads as sci-fi.
Rocket: speed 17 -> 38 and a real JET plume. The plume is laid down tight ON the flight line
(jet, spread along this frame's step, near-zero velocity, additive hot-white -> orange -> grey)
instead of scattered sparks with their own velocities, which looked like a firework.
Beacon: a finer trail (trail 1 at trailR 0.035, one-pixel motes) and a much brighter ball
(glow 3.2, a two-ring additive bloom and a fat white core), so the BALL is the bright thing and the
trail only says where it has been.
The bug in the middle of all that: firePellet stamps a whitelist of fields onto the round, and
jet/trailR were added to the WEAPONS rows without being added to that list — so the rocket flew
with no plume at all and nothing errored. Any new pellet field has to be added in both places.
Session log: fire, ash, and the third laser pass (2026-08-03)
A BURNT BODY DOES NOT BURST. Fire kills by cooking, so there is nothing left to spray: a body
that dies while alight — flamethrower or flame ball — leaves dark grey ash instead of blood. Same
decal, same mound, same particles, all recoloured and a little smaller, plus a puff of smoke and a
dull thud instead of the wet splat and the headshot crack. killZombie reads zb.burnT > 0 || zb.ashes; the flame BALL has to set zb.ashes itself, because that kill happens before the body
ever catches. gore/piles/parts all carry an ash flag and render off ASH_COLS.
The flamethrower is a lighter, not a gun. dmg: 0, hp: 0, pierce: true — it does NO impact
damage at all and only IGNITES; the burn does the killing, so a lit crowd walks a few steps and goes
down together. Reach life 1.15 s x speed 23 = 26 m against a 22.8 m court, so it crosses to
the far baseline. Watch the guard in the pellet loop: hp -= 0 fell straight through to
killZombie, which made the flame cone delete the crowd on contact — a machine gun that happens to
be orange.
Every burst weapon had no progress bar, and the cause was the spend. Arming spent the tin
immediately, so the rack entry vanished — and the countdown bar draws on the slot holding G.gunId,
which no longer existed. The tin is spent when the burst ENDS now (endBurst), so the icon, the
count and the bar are all there while it runs. Every path that ends a burst must route through
endBurst — the duel, the boss fight and death all force G.mgT = 0 on their own, and any one of
them missed would hand out free ammo. The soak asserts gunId is never set with mgT at zero.
Auto-fire movement, again. The previous fix set the pose from moving, but moving was
mx || mz || assist, and finger-steering zeroes mx/mz — so a player sliding left and right under
auto-fire still counted as standing still and never ran. mgMoving is in that expression now.
The mg racket is GONE. It was a procedural racket drawn over a sprite that already holds one:
straight up-court it projected onto the player's back as a floating oval, and offset to the side it
just stuck out. Oliver: "can we please just hide it?" The pellets streaming up-court are the
feedback. drawMgRacket is deleted, not commented out.
(Previously) the oval on his back was the mg racket. drawMgRacket put its head at (P.x, 0.95, P.z - 1.35),
which at this camera projects almost exactly onto the player sprite. Offset by P.facing it reads as
a racket held out beside him.
A third array had the empties-under-the-iterator bug. A rocket blast can bank the last kill of a
game, and the beat that follows (a duel, a chapter boss) does pellets.length = 0 — so the pellet
loop's next index read undefined and the frame threw. shots, pellets, extras and the body loop
all carry the same if (!x) continue guard now. Any array iterated in updateZombies that can
contain something whose death advances the game needs it.
A gradient that ends at alpha 0 hides the thing you are aligning. The laser's far corners were pinned correctly from the first attempt, but each wedge layer faded to fully transparent along its length — so the beam became invisible before it reached the pinned edge, the VISIBLE end sat well short of it, and moving the geometry appeared to do nothing twice in a row. The far stop is lit (0.62 of the layer's alpha) now, so the visible edge and the pinned edge are the same line.
The beam falls off with distance — brightest at the strings, thinning down the court, because that is what a beam travelling away from you does. The far stop is 0.32 of the layer's alpha: enough depth to read, never zero, so the visible end stays the pinned end.
The laser's far edge is pinned in SCREEN space. Running out to beamLen on the ground landed it
on the ads' TOP line; it should die at their middle. c3.y = c4.y = proj(0,0,-6).y - AD_H / 2 — the
x's keep their perspective, the y is pinned. That holds in both orientations and leaves beamLen
free to be the KILL range instead of a number tuned to one camera.
Weapon sounds are a sfx field on the WEAPONS row: thump (beacon, boom), swoosh (rocket,
whoosh), otherwise the gun's every-other-round tick.
THE BLINKING BODIES, and why a missing frame is worse than a missing sheet (2026-08-03)
Oliver: "on iphone the zombies were blinking in and out of existence". drawZombie validated its
frame count against the LAST frame only — if (!SPR['z_' + v + N]) N = 2 — so a set with, say,
z_h2 missing but z_h4 present kept N = 4, the walk cycle stepped onto a name that had never
loaded, and drawStatic silently drew NOTHING. The body vanished for that frame of the cycle and
came back on the next one, forever. A single cached 404 (this project's oldest trap) is enough.
if (!SPR[name] && SPR['z_' + v + '1']) name = 'z_' + v + '1'; — hold frame one. A body that stops
animating is a bug you can see and diagnose; one that flickers in and out reads as the game
breaking. Every silent SPR[x] ? x : fallback in this file deserves the same suspicion.
And no ctx.filter, anywhere. The cut line was briefly the sprite redrawn clipped to a band and
flattened to one colour by a canvas filter, which put it exactly on the silhouette. On an iPhone the
line did not draw AT ALL. Whether that was the filter being unsupported or leaking past its
restore(), a rendering feature that fails silently on the target device is not worth a pixel of
polish — the line is a plain bar now and crosses the odd gap between the legs, which is the trade.
The laser plane, and cutting bodies with it (2026-08-03)
Settled with Oliver in discussion, then built. The beam is a real plane at TUNE.beamY (1.15 m)
that holds that height all the way out instead of sloping to the ground — that single change is what
makes the rest possible. Sloping, it lay flat on the grass across the whole far half, so there was
nothing for it to cut exactly where the crowd stands.
beamLenis the DRAWN length now, not just the kill range. It used to be solved every frame so the far end landed on the middle of the ad strip, which made it a knob that visibly did nothing. One meaning beats one clever behaviour.TUNE.beamBandis a live A/B between two looks, flippable from the T panel on the phone. 1 (default) = the BAND: drawn the way the ball's aim trajectory is drawn — a path down the lane whose width comes from each point's OWN projection scale, so it narrows into the distance like everything else on the court, with a shadow on the lawn to pin it there. 0 = the CONE: the solid widening triangle. The cone IS a true projection, but it grows in world width (0.06 m ->beamW1), so on screen it opens out while the court recedes — and the eye reads that as a flat shape stuck to the screen. Oliver: "it does not look correct perspective wise". The KILL follows whichever look is on (beamBandW * 2vs the cone's taper) in BOTHfireBeamanddrawBeamCuts, or the comparison would be a lie — measured, the cone takes 9 of 9 bodies in a lane and the band 5 of 9.- THE PLANE IS OPAQUE, and that is what settled it. While it was translucent, its opacity and
the over/under read were the same dial pulling in opposite directions: readable enough to show the
split and it veiled the whole cone, subtle enough to look right and you could not tell above from
below at all. Solid colour ends the argument — a body above the plane is drawn on top of it, a body
below is simply hidden behind it. Depth is carried by BRIGHTNESS (full at the strings, half at
the ads) rather than by alpha, which is why it still reads as receding.
BEAM_EDGE/BEAM_MID/BEAM_COREare the three colours. The trade is that you cannot see the crowd through it. - The fill is TWO gradients, matching the reference Oliver sent: a horizontal one across the width (near-white down the centre line, falling to the weapon's blue at the edges) and a depth one along the length. Canvas has no 2D gradient, so the wedge is filled in eight slices along its length, each with its own across-gradient at that slice's depth alpha — enough slices that the seams vanish. The two long edges are STROKED: the reference reads as a plane because its edges are drawn, not merely where the fill stops.
- One flat translucent triangle, apex at the player's CHEST (
beamOriginY1.25 — emitted from the racket head at 2.2 m the apex hung in the air and read as detached), opening out down the court, alpha falling with distance. It was four nested wedges compositedlighter, and that layering WAS the banding: additive light blowing out to white where layers overlapped. Flat alpha also means a body under the plane is TINTED rather than brightened, which is what makes the cut read. - Bodies are sliced by it.
drawBeamCutsruns after the beam: for each body in the cone, the part aboveproj(zb.x, beamY, zb.z).yis redrawn clipped, so it sits ON TOP of the plane while the part below stays tinted underneath.drawZombiemutates nothing, so drawing it twice is safe. Measured: the plane takes a shambler at 64% of its height (sternum), a brute at 43% (belly), the roller man at 36% (thigh) — bigger bodies visibly take it lower. - The cut line follows the SPRITE, not a rectangle. A
fillRectspanned the body's bounding width and so crossed the empty gaps between the legs and under a held-out arm, reading as a bar lying behind him. The line is the sprite itself, redrawn clipped to a thin band and recoloured flat byctx.filter. Thickness scales withh * s²so it is a hairline at the ads and a real band at your feet — the camera's scale only spans ~1.5x across the court, so a linear falloff was invisible. - Getting a filter to a specific colour is a MEASUREMENT, not a guess. The chain has to leave a
MID grey for
sepiato tint:brightness(0) invert(1)goes to white, and sepia adds almost no chroma to white while saturating a colourless pixel does nothing — the first attempt rendered #a0b4c3, a grey.brightness(0) invert(0.45) sepia(1) saturate(1600%) hue-rotate(185deg)renders (32,154,255) against the laser icon's (29,153,232). Probe candidate chains by filling a rect and reading the pixel back; do not eyeball it. HAS_FILTERis feature-detected and the line falls back to a narrow bar without it (older WebKit has noctx.filter).- The cut pass is BOUNDED (
CUT_MAX8 nearest bodies,CUT_MIN_PX14): it draws each body twice more and the second pass runs through a filter, which is slow on iOS, and it runs every frame you are AIMING rather than only on the shot. Measured on desktop: 0.27 ms/frame with 20 bodies against a 16.7 ms budget.
T.laserScene() — the tuning playground
T.laserScene() in the console (or #laser on the URL) poses six bodies of different types at known
depths with the laser armed and a pull held. T.laserFire() shoots it; T.laserScene() re-poses.
It does NOT pause, and that is the point. The first version froze the world so the picture would
hold still — and then every value changed in the T panel did nothing visible, because nothing
redrew. Oliver: "tuning the parameters does not change the view (because it's paused?)". The bodies
are held still individually (v = 0) and spawning is off instead, so the picture is stable while the
render loop keeps running. Never pose a tuning scene by halting rAF or pausing — a still frame
you cannot change is worth nothing.
A boolean living in TUNE must be tested with >= 0.5. The panel renders every TUNE key as a
number input stepping by 0.02, so one nudge on beamBand set it to 0.02 — still truthy — and the
renderer silently switched variants under him mid-tune.
The laser's shape was tuned this way and these are Oliver's numbers: beamY 0.95, beamLen 23,
beamW0 0.08, beamW1 3.1, beamOriginLift 0.44, beamBorder 0.18.
beamY and beamLen interact — a lower plane meets the ad strip at a shorter distance, so
dropping one wants the other shortened.
T.pause(true) — freeze the world, keep drawing
T.pause(true) stops update() but keeps render() running, so a value changed in devtools shows
on the next frame while the scene stands still. The older recipe halted rAF
(requestAnimationFrame = () => 0), which freezes the PICTURE too and is no use for judging how
something looks. T.pause(false) runs again.
TEST MODE — triple-tap the scoreboard
A sandbox for trying new content without playing twenty games to reach it. Three taps on the
scoreboard (main.js; the umpire's neighbouring triple-tap cheat is deleted) toggles
G.testMode; three more turns it off, and a SINGLE tap is still the miss-log overlay.
- a fixed MEDIUM wave:
testZMax8 bodies,testZSpeed1.15 m/s,testSpawnEvery1.6 s — it overrides the difficulty director rather than editing it, so the sandbox is the same whether you opened it in game 1 or game 40, andG.runGameskeeps climbing underneath. testLevel20 is the roster levelpickVariantpretends to be at, so every body type can appear.- every barrel weapon on the rack, TOPPED BACK UP each frame (
stockTestRack), so committing a burst — which spends the whole tin — leaves it available again a moment later. - a barrel every
testBarrelEveryseconds, so the pickup itself is testable.
The third tap must not toggle logShow. Taps one and two already toggled it twice, so it is
back where it started; toggling again in the triple branch leaves the miss-log overlay switched on
behind you every time you enter the mode.
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().
- Two consecutive
render()calls do NOT produce the same frame, so "draw it, change one thing, draw again, diff the pixels" silently measures nothing.render()mutates camera state as a side effect (aimCam += ((G.aim ? 1 : 0) - aimCam) * 0.15), so the whole scene shifts slightly between grabs and the diff lights up everywhere — it reported a laser beam extending 16 px above where its own geometry could possibly reach. To check WHERE something lands, recompute the projection directly (T.proj(...)) and compare numbers; keep pixel-diffing for "did anything at all change". - The module-refresh list MUST include the VERSIONED entry point.
index.htmlloadssrc/main.js?v=N, andfetch('src/main.js', {cache:'reload'})does not bust that URL — so main.js alone stays stale through a reload while every other module is fresh. Read the src off the page:document.querySelector('script[type=module]').getAttribute('src')and fetch that too. Symptom: a change in main.js appears to do nothing at all, repeatably. - 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):
- RETIRED 2026-08-08: the ~91.7% CPU return rate. The opening CPU was deliberately softened
(aiFlub 0.18 + aiFlubLoss rubber band, see that session log) — expect ~82% at 0–0, less as it
drops games. The differential method below is still how to prove "no unintended regression".
The old record, for the archaeology:
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
WHICH TOOL FOR WHICH JOB — read this before opening any _*.py (2026-08-14). A fresh session
reaches for _regen.py first, because it is the biggest file and the oldest name — and for most jobs
it is the WRONG tool, superseded by the reference-sheet flow. Route by job:
| The job | The tool |
|---|---|
| A whole opponent family (31 sprites, one character) | _famroll.py -> _zsheet.py — see "THE ZOMBIE FAMILY PIPELINE" below. Never _regen.py: nine separate rolls is nine chances for the character to drift, which is why the family pipeline exists. |
| Rebuild the pose reference | _famref.py — composed from disk, no API call, gated. |
| A PLAYER RUN CYCLE | _vidroll.py — a video model, not a still one. See "THE RUN CYCLES CAME OUT OF A VIDEO" below. _runroll.py still rolls the single frame you film FROM, and still serves the opponents' pairs; it cannot produce a gait. |
| A stride twin (legs frozen) | _stride.py. |
| THE SAME CHARACTER IN SOMEONE ELSE'S CLOTHES, every pose at once | _frontroll.py — famroll's trick pointed at the tennis poses: image 1 is the ten o_* frames composed from disk, image 2 is the body to dress them in. Two calls made the whole netplay family. Ten _regen batches would have been ten chances for the man to drift. |
| Single-sheet remaster, boss roster, any non-family sprite | _regen.py + _regen_batches.json — the only jobs it is still for. |
| Standalone art (logo, panels) | _key.py; coarse legacy sheets, _slice.py. |
| The CROWD — the art itself | _crowdroll.py (three rolls: seat, then clap from it, then mid from that) -> _crowdpass.py (throws the drift away, keeps the arms, stacks the frames, grades the three courts, writes src/crowd.js). Every frame of all three courts comes out of one run; there is no such thing as editing half of it. |
And when installed sprites look WORSE than the _refcheck/fam_*.png sheet they came from, the
suspect is the CUT, not the roll — the sheets on disk are the evidence, and a re-cut is free (see
"Only ink votes" below). Seven families shipped pixelated for two days because every fresh look
started from "the generator drew it badly" instead of diffing sheet against sprite.
NEVER SAY "TRANSPARENT" TO THE MODEL — name the flat hex (re-learned the hard way 2026-08-14).
The model has no concept of transparency: the word summons a PAINTED CHECKERBOARD background, and
"cuts out later" invites grey improvisations. Every background/bed instruction names ONE concrete
flat colour ("ONE FLAT solid #FF00FF across the entire canvas") and never mentions cutting,
keying or transparency. This rule already lived in _regen.py's CANVAS block; a freehand one-off
prompt bypassed it and cost three rolls — REUSE THE CANVAS BLOCK (import it from _regen.py)
instead of re-writing background prose in scratch scripts.
STRING BEDS ARE MAGENTA ON PURPOSE, and the cutter must be told (2026-08-14). Prompts ask for
#FF00FF racket beds so they cut to transparency (the famroll rule). But _fine.key REPAINTS
enclosed magenta pale grey — protection from the era when magenta beds were an accident — which is
how the tennis opponents shipped holding solid white paddles. key()/main() take bed=True now
(_runroll and the opponent-refresh cuts pass it; _zsheet has its own equivalent). New cut path
for a sheet whose beds are meant to open: pass the flag, or the paddles come back.
-
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. 12b.
_regen.pyhas THREE style blocks now, and picking the wrong one costs rolls. The shared block used to be one constant that always said "keep every pose, every colour EXACTLY as in the reference" — correct for a remaster, and it fights every word of the subject for anything else.
- default (
STYLE): remastering a sprite that already exists. "newart": true(NEW_STYLE): a NEW character drawn in the reference's style. Drops the fidelity clause and says explicitly not to copy the reference's body or outfit."state": true(STATE_STYLE): the SAME character in a new state — wounded, mid-throw. This is the one that is easy to get wrong, becausenewartlooks right and isn't: it licenses a repaint, and it got one. Five wound sheets came back with living TAN skin and a redesigned shirt while the healthy frame stayed a grey-green corpse, and the coach's navy tracksuit came back mid-blue (torso RGB blue channel 111 -> 138).STATE_STYLEpins colours, clothing and build to the reference and states outright that he is a corpse, never pink or tan. A brand-new sprite name must exist on disk before its batch runs —run()measuresdensity()of the file it is about to replace, andcolour_matchopens it. Seed each new name by copying a body of the right size and facing, and usecheck: same/loosesocolour_matchis skipped and the seed's palette is NOT inherited. The trap in that: a batch that silently fails leaves a perfect copy of another character behind, which renders as working art. Assert every new PNG actually differs from its seed before shipping (the scratchpadcheck_seeds.pydoes exactly this).
-
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. -
NOTHING IN A SPRITE MAY BE PINK, MAGENTA, VIOLET OR PURPLE. The chroma key does not merely match the corner colour, it deletes every pixel where red AND blue both beat green by 40 (
_key.py:min(r, b) > g + 40, and_fine.pyde-fringes the key twice). So a "vivid magenta drum" and a "magenta laser pistol with a violet coil" both came back as hollow outlines — 19% and 15% solid pixels against 50-56% for the icons drawn in other colours. Measurealpha > 128as a fraction of the box if a sprite looks thin; that is the whole diagnosis. Pink can only ever be PROCEDURAL — the weapon barrel is grey steel with yellow chevrons in its PNG, and every bit of its pink is the halodrawBarrelpaints around it, which never goes near the keyer. State the forbidden colours in the prompt explicitly; "make it pink" is a request the pipeline cannot fill.
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.
THE ART PIPELINE, corrected — what a day of chasing the generator taught (2026-08-12)
A whole day went into sprite consistency and most of it was spent solving the problem in the wrong
place. Read this before touching assets/_regen.py, _fine.py or _master.py; it will save the
same day.
The boundary that everything else follows
| what it is good at | who does it |
|---|---|
| identity, pose, composition | the generator, given reference images |
| exact colour values | the pipeline, deterministically |
| layout accidents — clipped limbs, grids, merged frames, wrong frame count | the gates |
Six gates were built in one day trying to make a generator do arithmetic. It cannot. The proof is
unambiguous: the brute's serve sheet came back with his build, his garments and his DARK TONES
pixel-identical to the reference — (56,56,32) in both — and his big pale shirt two shades
brighter, (200,200,184) against (176,176,152). The model was looking straight at the character.
Every call re-renders, and re-rendering re-lights. No prompt fixes that; match_to_anchor() does,
in one pass, retroactively, on art already on disk.
A REFERENCE BEATS A DESCRIPTION. Every time, at every level.
This is the single highest-value rule here, and every failure of the day was a violation of it:
- The racket zombie's play sheet was generated from
[z_c1, o_idle1, o_run1, o_swing1]— the character as an IMAGE, the poses as IMAGES — and landed first time. - The brute's three sheets each restyled from the master with the character in words, so each one re-derived "pale grey-green work shirt" from the same paragraph and the game got three different brutes.
- Wound stages only started accumulating damage when stage 2 referenced stage 1's SHEET instead of being told in prose that the arm was still missing. The arm kept coming back because the reference picture had two arms in it, and a picture beats a paragraph — including when the picture is wrong.
Corollary: every corrective sentence in a prompt is compensating for a reference you did not supply. When a prompt grows a third "do NOT" clause, add an image instead.
CORRECT, THEN CHECK
match_to_anchor() runs before the palette gate, so the gate is a safety net for what arithmetic
could not fix rather than a tripwire for what it can. It pairs each of a frame's dominant colours
with its nearest anchor colour, fits one gain+offset per channel, and applies it to every pixel.
Pairs further apart than NEW_COLOUR are dropped from the FIT — those are colours the anchor
genuinely has not got, i.e. blood on a fresh wound — but they still RECEIVE the transform, so a
wound stays the right red relative to the body it is on.
CALIBRATE BEFORE YOU GATE
The discipline that was missing all day. Every threshold in _regen.py was picked by measuring
frames known to be right against drifts that were visibly wrong, and rejecting the formulations that
overlapped. Three formulations were tried for the palette gate; two overlapped and were thrown away.
Do not add a gate without printing that table first.
The gates, and the numbers they were calibrated on:
- stride — is a walk cycle ONE DRAWING WITH THE LEGS MOVED? Height ratio, upper-body delta, leg
delta on a feet-aligned overlay. Good pairs: brute lurch
1.00/0.000/0.638, shambler1.00/0.070/0.520. Failures sat at upper 0.47-0.85 (two unrelated illustrations) or legs 0.09 (the same picture twice). Opt-in per batch, because a four-frame RUN legitimately swings the arms. - palette — are the big colour areas the anchor's colours? TOP SIX by area, proportions ignored
so a pose change is invisible, and blood is never in the top six so a fresh wound cannot look like
drift. Right frames 0.0-8.5, visible drifts 12.0-15.9, threshold 10. This one number replaced
four hand-picked features (
kit's shirt redness, shorts brightness, shorts hue, near-white fraction), every one of which a different character broke — the brute's shirt has no red in it at all, two of the four move with POSE, and none of them ever measured SKIN. - changed — did the wound actually happen? A copy scores 0.02-0.05, an amputation 0.12-0.38, a decapitation 0.27-0.75. Measured whole-body OR above-the-hip, whichever moved more, because an arm is a body-relative amount of silhouette: 12-38% of a lean man, 9% of a brute.
- no grids — asked of each cut PIECE, not of the sheet. A body is vertically continuous, so a full-width empty band inside one piece means the cut caught two of them.
- plus the older ones: frame count, magenta background, nothing touching a canvas edge, inner-edge density.
Traps, each of which shipped something silently before it was caught
- A rescue that turns a loud failure into a quiet one is worse than no rescue.
_fine.pygained a valley-cut fallback so a sheet whose figures touch is not thrown away — and it happily sliced a 2x2 GRID into four frames of a torso above someone else's boots. It installed cleanly and read in the log exactly like a success. - A failed stage must block the stages drawn from it. The brute's headless sheet installed off an
arm-off stage that never landed: three perfect frames of a two-armed corpse. Running was worse than
failing, because it SUCCEEDED.
_regen.pynow skips a batch whoseref_onlyis in a failed batch's outputs. - A placeholder is a stand-in for art that does not exist yet, and nothing else. Re-running
--variantto rewrite a spec re-seeded EVERY placeholder from the master and silently replaced a finished brute's serve and fall sheets with copies of the lean man they were posed from. Three passes were then spent regenerating sheets that already existed.variant()never overwrites now. - The detail bar RATCHETS against the file being replaced, so a chained stage measured against a copy of the master has to out-detail art two steps better than the stage it follows. Chained placeholders are seeded at RUN time, from whatever the reference has become by then.
_fine.pykeyed every magenta pixel, not just the background — and the generator paints racket strings in the backdrop colour more often than not, so sheet after sheet installed a body holding an empty hoop. Background is the region connected to the border; magenta left INSIDE a body is repainted pale grey. (Image.fromarrayimages are READ-ONLY and PIL'sfloodfillswallows the write error, so the first version of that fix was a silent no-op.)- Put the background rule FIRST in the prompt. Six rejections in eleven rolls were the generator painting the zombies on a tennis court, because the magenta instruction sat behind three hundred words of "keep everything exactly as the reference".
Still open
changeddoes not know WHAT changed. It passed a "headless" brute that still had its head, because something moved. It measures that a frame differs from its reference, not that it differs in the right way, and no cheap measure of "the head is gone" has been found.- A variant is reliable for its play sheet and a gamble for its wound stages. The brute's healthy
eleven are good; its arm-off fall frames are standing figures of the wrong build and its headless
row is not headless.
--variant <key>and_master.py's master sheets are the right shape; the wound half needs the reference discipline applied harder than prose currently applies it. - The brute is PARKED, not deleted:
config.ZFAMSis one entry away from making it live, its 31 frames are on disk, and its nine batches are in_regen_batches.json. SUPERSEDED 2026-08-12 — see the next section. Those 31 frames were not art:--verifyfound 27 of them dead. The brute is now live (ZFAMShas'br', the KEEPER wears it) off ONE sheet.
THE CROWD, ROLLED AGAIN — AND ONE SPECTATOR AT A TIME (2026-08-21, same day)
Oliver on the pass below: "the still look a little rough.. and now the new lighter areas read as rather sloppy artwork. please regenerate the crowd art and make it more detailed. also the animation also looks pretty artificial. the flashes from the fotographs are too rare and the the whole thing somehow does not translate well to the other courts. also when the opponent makes a point there is an aww-sample (which has an applaus trail) so the crowd should still clap but more subtle."
All five, and the first one retracts a claim the section below made: there IS a key — the roll
scripts read OPENROUTER_API_KEY from the repo's own .env, not from the environment, so "no API
key in the environment" was a wrong conclusion, and painting detail onto an old sheet with numpy was
never the right tool. It is deleted. The art is rolled.
- THE ART IS A NEW ROLL (
assets/_crowdroll.py, two calls). Individually drawn spectators — hair, hats, sunglasses, drinks, cameras, small flags, plain banners, empty seats, stairways, a few already on their feet, and three zombies sitting in the middle of the crowd behaving like furniture. The prompt states the art resolution in FIGURE HEIGHTS ("about 90 pixels of height instead of the reference's 40"), which is the one lesson the sprite rolls paid for: the generator picks its own canvas, so a pixel count means nothing to it and a proportion does. The old sheet goes in as image 1 for LAYOUT ONLY — same camera, same tiers, same railing. - ONE ROLL, THREE COURTS. Each court is a per-channel histogram match onto its own old sheet, so clay is still terracotta and the night court is still night, but the crowd underneath is one drawing. The same LUT grades both frames of a pair, which is what keeps them a pair.
- THE ANIMATION IS PER SPECTATOR. The clap roll drifts by 29% of its pixels;
_crowdpass.pykeeps only the blobs that were empty stand and became a person — the raised arms, 1.2% of the sheet — and writes a box around each intosrc/crowd.js. render.js draws ONE BOX AT A TIME, so a single spectator's arms go up. The 16-column strip swap it replaced moved a seventeenth of the crowd as a block, which is what read as machinery ("the animation also looks pretty artificial"). Boxes that overlap are merged into one entry: two neighbours who always rise together are one person as far as the animation is concerned. - THE SIZE OF THE MOMENT IS NOW A HEAD COUNT.
crowd.popdecides HOW MANY of the 93 join in (each on their own period, staggered by a stable per-person draw), so the same code does the whole range: a groan is a dozen people, a game is half the stand, and past 0.75 it becomes the wave — which is now a front travelling person by person rather than column by column. - THE AWW CLAPS.
tennis-awwis a groan that settles into scattered applause, so a stand that sits through it contradicts the sound.aww()now sets a smallcrowd.popitself — in audio.js, because it is the SAMPLE that makes this true and every current and future caller should get it. - MORE PHOTOGRAPHERS.
TUNE.flashEvery5.0 -> 1.9. At a five-second mean, jittered, spread over a stand this wide, Oliver never saw one.
The clapping was invisible, and the stand was too loud (2026-08-21, same day)
"when clapping I barely see any movement at all now.. also all of the art could be colored a little more dim as it is perspective wise farther in the background and should not steal too much attention." Then: "can't we make an animation sprite sheet of the new crowd?" — which is the right question and the answer to the first half of the first one.
- A THIRD ROLL, and the sheets became animation sheets.
_crowdroll.py midtakes the arms-up frame as its reference and asks for the same standing people with their hands brought together in front of their faces. It drifts harder than the first (53% of pixels) and it does not matter: the same filter keeps only stand-turned-person. 100 arm blobs in pose 1, 74 in pose 2, 134 spectators once merged, 28 of them with both poses — those 28 cycle between two raised poses, which is a clap; the rest alternate with the seated frame, which is arms going up and down. Either way the stand is now MOVING rather than holding, and that is what the eye catches at 8x6 logical px. Measured: 2-3% of the stand band changes every 90 ms during a won game, against near zero before. - One file per court instead of two. Three frames stacked vertically, graded as one image so the
frames cannot drift apart in colour, and
x_crowd2/x_crowd_c2/x_crowd_h2are deleted. TUNE.standBack0.22 — the stand pushed back, applied in the one place both the cached backdrop and the live raisers read. It is a DEFAULT, not a floor: stacking it on top of the two courts that already dim themselves buried them, socourt.dimreplaces it, centre court claims a 0.06 of its own, the night court came down 0.62 -> 0.5, and centre court's sheet is graded off a lifted target so there is something under the dim to see.
THE STAND STOPS WOBBLING, AND STARTS BEING A CROWD (2026-08-21)
"the crowd spritesheet could use some love (resolution and movement wise)... also the crowd wobbles with a client side animation right now.. that feels odd. also add with a little bit of humor" — and, mid-build: "the crowd could be more detailed (and I don't [mean] the technical resolution of the image)".
The wobble was never the animation. x_crowd2 was a RE-ROLL of x_crowd, not a pose of it:
13-16% of its pixels differed, spread over faces, torsos, seat backs and the railing. Swapping 16
columns between them at 4.5 Hz therefore re-hashed a strip of crowd twice a second. Every knob you
could turn on the animation was the wrong knob.
assets/_crowdpass.py rebuilds the pair instead, and establishes the invariant in "Architecture &
invariants". Three things fall out of the fact that the three courts are one composition in three
palettes, pixel-aligned:
- the art pass finds its people ONCE (on the clay sheet, the cleanest — grass carries the brightening curve's noise) and applies the same masks to all three, so the three crowds stay the same people;
- a raise that exists in only ONE court's roll is transplanted into the other two through a colour
map learnt from the aligned seated pair (
colour_map). Per court the honest raises number about fifteen; pooled, 44 people can get to their feet. Eight of them are baked STANDING into both frames; - everything the art pass paints is excluded from the clap set, so no painted pixel can ever flicker. That ordering is load-bearing — the clap set is chosen FIRST and reserved, because a crowd this heavily painted leaves nothing that is allowed to move if you paint first.
RESOLUTION IS NOT THE PROBLEM AND ADDING PIXELS WOULD NOT HELP. Measured: the sheet is 1408x768 and the stand band is ~1080x784 device px on a 3x phone, i.e. already ~1:1 vertically and 0.77x horizontally (in landscape it is 1.36x wide by 0.54x tall — the sheet is stretched to whatever band exists, so spectators are anamorphic, differently per orientation; see OPEN). At that size a spectator is ~8 logical px, so "more detailed" can only mean chroma and silhouette, which is what the pass adds: 55 shirts and 44 heads of hair in colours the court does not own, 28 of those as caps, 4 flags on poles above the heads, 8 emptied seats so no row ends in a straight line of identical heads, and 8 people permanently standing.
HUMOUR, and it costs nothing: three spectators are already TURNED (grey-green skin, mid-row, nobody around them reacts) — and because a painted pixel is never in the clap set, they are also the only three in the stand who never applaud. It is a hint at the parked stands-turn-into-zombies step, not that step.
MOVEMENT — one draw path, three behaviours (drawClapColumns):
- applause: every column on its own phase, 3.5 Hz — a scatter of people rising and sitting;
- THE WAVE: a band ~2.5 columns wide crossing the stand every 1.4 s, and only when
crowd.pop >= 0.75— the set, the match, and the ones the umpire calls unbelievable. A game won ischeer(2.4, 0.6)and a wave on every held serve is a wave nobody looks up for; - the FIDGET: with nobody cheering, ONE column stands for a third of every
TUNE.fidgetEvery(4.5 s). Between points the stand used to be a photograph. The column walksslot * 5 + 3— five is coprime with 16 so it visits every column, where the first cut (* 7919, i.e.* 15) moved one seat sideways each time and read as a march.
PAPARAZZI SCALE WITH THE MOMENT (Oliver: "increase paparazzi burst during bigger events").
cheer(seconds, vol) now records crowd.pop (the size of the moment, 0..1 — vol was already exactly
that at every call site) and queues crowd.burst shutters proportional to it; tickFlashes drains
that queue at a fifth of the normal gap, and outside a burst the rate rides pop too. pop is
held at the loudest while an applause is live — a won rally landing inside a match-point ovation
must not demote the stand to a polite clap, and cheer() is called from a dozen places that overlap.
That is the one new state-machine rule here, and it has a test.
SUPERSEDED THE SAME DAY — see the section above. Two things in this one were wrong. The paint
pass ("55 shirts and 44 heads of hair...") is gone: Oliver, "the new lighter areas read as rather
sloppy artwork", and he is right — recolouring blobs of somebody else's drawing is not drawing. And
"no API key in the environment" was a wrong conclusion that made the paint pass look necessary: the
key is in the repo's .env, which is where the roll scripts have always read it from. What survives
from this session is the diagnosis (the frame pair, not the animation), crowd.pop, the paparazzi
scaling and the fidget.
THE SHELL — CAPACITOR, HAPTICS, PORTRAIT, SWIPE-BACK (2026-08-20)
The first three bullets of LATER item 2 ("What the shell buys GAMEPLAY-wise") are built. The web
build is unchanged and stays the primary surface — every line below degrades to a no-op in a
browser, and splatter-tennis.wuwi.one keeps deploying off the same repo on every push.
The zero-dependency rule bends exactly once and exactly here. package.json, node_modules/ and
ios/ exist for the SHELL. No JS from any package ships. src/haptics.js calls the bridge the
shell injects — Capacitor.nativePromise('Haptics', 'impact', { style }) — never
import ... from '@capacitor/haptics'. There is no bundler in this project, so a bare specifier
inside a <script type="module"> is a hard 404 the moment the same file is served from the web.
@capacitor/haptics stays an npm dependency for one reason only: cap sync installs its Swift pod.
Anything added to the shell later has to reach the game the same way.
THE TRAP, and it cost a rebuild to find.
Capacitor.Plugins.Hapticsreads like the obvious zero-dependency answer and it is silently wrong. The injectednative-bridge.jsdefinesCapacitor,isNativePlatformandnativePromise, but leavesPluginsEMPTY — it is@capacitor/core'sregisterPlugin, i.e. exactly the JS we are not shipping, that fills that object in. Going throughPluginstherefore falls straight through to the web branch inside the app, wherenavigator.vibratedoes not exist on iOS — which looks identical to a phone with haptics switched off, and would have been diagnosed as "the palette feels flat". Proven the only way worth trusting:simctl launch --console-ptyshows⚡️ To Native -> Haptics impactfor thenativePromiseform. That is the check to repeat for the next plugin;simctl spawn log streamwill NOT show it, because Capacitorprint()s those lines to stdout.
-
npm run wwwrsyncsindex.html, the manifests,src/andassets/intowww/with the sameassets/_*exclusion.dockerignoreuses — 32 MB, not the 463 MB the sprite pipeline sits on.npm run sync= tests + that copy +npx cap sync ios;npm run iosopens Xcode after it.www/andnode_modules/are gitignored;ios/IS committed (Capacitor's ownios/.gitignorekeeps the copied payload, Pods and Xcode's per-user junk out of it). -
Portrait is enforced where it can be:
Info.plistlists ONE supported orientation, so the app never rotates. That formally retires landscape as a surface (OPEN 6) rather than fixing it. The web half is best-effort —orientation: portraitin both manifests and one guardedscreen.orientation.lock()inmain.js— because iOS Safari has no lock at all and the browser build keeps doing whatever landscape does today. -
HAPTICS ride the sound map, as planned: six call sites, all of them places an sfx already fires. Your racket contact (light, medium on a perfect hit), the serve strike,
killZombie— the ONE funnel every weapon routes through —explode,hurtPlayer,killPlayer, and the weapon swap. The restraint rule is enforced inhaptics.js, not at the call sites: every kind carries a minimum gap, so a bomb that kills twelve bodies is felt twice (one blast, one kill) and the machine gun is a metronome rather than one long rumble.test.jspins exactly that. No mute switch on purpose — iOS already routes these through the system's own vibration setting. Untested on hardware. The simulator has no Taptic Engine, so every weight and gap below is a guess on paper and needs its own phone round before any of it is assumed good. -
SWIPE RIGHT GOES BACK in the menus — the direction iOS's own pop gesture goes. (Built leftward first, to the letter of the brief; Oliver flipped it the same hour: "that's the iOS way". The lesson is cheap and worth keeping: on a platform gesture, the platform's convention beats the literal wording of the request — say so up front rather than shipping it and flagging it after.) Every screen but the splash wears exactly one chevron, and that button already knows where back is for its own screen — so the gesture finds the visible
.chevand clicks it rather than keeping a second map of the wizard that would drift the first time a stage is added. Threshold 60 px, horizontal-dominant so the stats and board screens still scroll. Thetouchendlistener is deliberately not passive: a swipe that starts and ends inside one of the big poster cards also produces that card's click, andpreventDefaultis what stops the screen answering forward in the same frame the chevron walked it back. -
SOUND, and it was broken twice over on the first shell build. Oliver, minutes after it ran: "I don't hear any of the games sounds.. daniel the umpire is synthesized which is horrible and no music is playing on start". Two independent causes, both now fixed, both worth knowing before the next plugin:
fetchresponses from the app bundle are OPAQUE. Capacitor servescapacitor://localhostthrough a customWKURLSchemeHandler, andfetchreports those asstatus: 0, ok: false— with the full body present and decoding perfectly.audio.jsguarded all three of its loads with a plainif (!r.ok) throw, so inside the app EVERY recorded sound failed at the door: the umpire fell through to the synthesizedsay(), no bed ever started, no sample ever played. The synthesized sfx kept working, which made it read as a mixing bug rather than a loader one. Fixed once, in the sharedbytes()helper every audio fetch now routes through: a status of 0 is the absence of an HTTP status, not a failure — trust the status only when there is one, so a real 404 still falls back. Any future!r.okguard on a bundle-relative fetch is this same bug. (stats.jsis fine: its board calls go to the live host over real HTTPS.)- The ring switch muted everything on device. WKWebView's WebAudio runs in the SoloAmbient
session, which the hardware silent switch kills outright — a completely silent game on a phone
that lives on silent, with nothing on screen to explain it.
AppDelegatenow setsAVAudioSession .playback, the games-and-media category. That is a shell-only fix; the web build has no say in it, and it is a good part of why the shell is worth having for sound.
Confirmed in the shell after both fixes: the bed reaches
PLAYING, the context isrunningwith no gesture at all (autoplay really is allowed in WKWebView, as LATER predicted), and thesay()fallback never fires. On the web, a genuinely missing file still falls back — checked. -
The app has no server behind it. Inside the shell the page is served from the bundle (
capacitor://localhost), sofetch('api/scores')and the netplay socket would have resolved against the app's own files.config.NATIVE/config.APIand one branch innet.jspoint the shell at the live host by name; the web build keeps the page-relative form, which is the only reason localhost, staging and prod all work off one file with no switch. Consequence to remember: the app always talks to PROD, never to staging.
Verified: node test.js green (105) across four seeds; the app builds and launches on the iOS 26.5
simulator; isNativePlatform() reads true inside it (so the board really does point at prod) and
both bridge calls reach the native plugin; swipe-back walks the whole wizard (mode -> modesub ->
back -> back -> splash) in the browser pane, and vertical drags, short slides and rightward swipes
correctly do nothing.
Not verified: anything that needs a real device — the Taptic Engine, 60 fps under WKWebView at
RES 4 (OPEN 1, and LATER named this as the second place that risk lands), and WKWebView audio.
THE FRIEND ACROSS THE NET WEARS YOUR KIT, FROM THE FRONT (2026-08-20)
Oliver, on a netplay match: "the opponent color is displayed correctly, but the shirt is colored not
the trousers (men) and for women it is not the whole dress" — and his own diagnosis was the right
one: we did not have the player character from the front. The friend was drawn on the ROSTER
opponent's sheets (o_*: red polo, dark grey shorts) and dyed on a third hue arc, TINT.oKit.
Red is the one hue a kit cannot own on this art, and no arc could have fixed it. Both halves were measured before anything was generated:
- On the men's sheet the only saturated red is the SHIRT — the shorts are neutral grey at saturation 0.05, so a hue rotation can never reach them. Your own pick dyes your SHORTS. Two people in one match, wearing their colour on different garments.
- On the women's sheet the red top sits at the same hue as her SKIN and her HAIR (top h≈0 and 336-355, shaded skin h 13-24, and the arc's own floor is satKit 0.15). Rendering the shipped tint with the ACID pick put green blotches down her arms, her legs and her ponytail. That was live, and nobody had looked at it.
The repaint was tried first and it fails, which is worth knowing. Mapping the red kit to white
and the grey shorts to blue offline — no API, no new art — gives a genuinely good MAN (white polo,
blue shorts, right shoes) and a ruined WOMAN: the same hue overlap eats her hair, her face and her
leg shading. _famref's bed_magenta is also a trap on this art — it repaints the largest PALE
blob, which is the racket bed on the z_r* master but a SHOE here, so _frontroll.open_bed() asks
whether the bed is already keyed out before touching a frame.
So the friend got his own sheets: n_*, the player's kit drawn from the FRONT, tinted through the
PLAYER's arcs and the tour's own mode — his shorts dye, her whites wash, exactly as yours do.
TINT.oKit is deleted. The roster CPU keeps o_* and stays as drawn; it is not a person.
Two rolls made all twenty sprites, and the method is the reason. _frontroll.py is _famroll's
trick applied to tennis: IMAGE 1 is the POSES (the ten installed o_* frames composed from disk at
one scale, 4/4/2, gated for band and figure count before a call is made), IMAGE 2 is the CHARACTER
(p_idle1, from behind — the model is told outright that it is inventing the face). _zsheet.cut()
takes a want/level pair now so a non-zombie family can reuse the cutter; level names the frame
groups that must come back the same height (the idle pair, the bounce pair, the stride pair — the
serve toss and the smash are meant to be taller and are left alone). Heights came back within 1 px
of the o_* originals they were composed from.
The one thing the first roll got wrong: it drew a TENNIS BALL into both bouncing frames. The game draws the ball; a drawn one is a blob stuck to his hand. The prompt says so now.
THE TOUR RIDES THE WIRE TOO (Oliver, same session: "ja, das geschlecht sollte auch über die
leitung gehen"). A tour is which BODY you play in, so it is part of the look and it goes in the same
message (u, alongside k/s/n). Before that, both phones drew the friend in whatever tour THIS
phone picked, so a men's/women's pairing showed each player a body their friend was not using.
oTour() in render.js is the one accessor — yours everywhere, theirs in netplay — and it feeds the
three places that must agree: the sprite suffix (SK), how the art takes a colour (PS: her whites
wash, his blue dyes) and the palm the idle ball bounces under (BOUNCE_IDLE.oDx/oApex, measured per
body). An older build sends no tour and falls back to ours, which is exactly what it used to do.
Those palm knobs were re-checked against the new art rather than assumed: the ball marker still
lands under the drawn palm on n_bounce and n_bounce_f at the values measured off o_bounce,
because the poses were copied. No retune.
And the SETUP SCREEN uses the front too (Oliver, same session: "das heisst aber auch, dass wir
in der farb- und charakterauswahl den frontfacing nehmen können"). Picking a face off the back of a
head was the best the art allowed; drawLook draws n_idle1 now — same kit, same palette, same
tint pass, so it still cannot drift from what walks on.
THE RUN CYCLES CAME OUT OF A VIDEO — the still models cannot draw a gait (2026-08-15)
A ROW OF POSES IS NOT A WALK CYCLE, and six sessions of prompting could not make it one. Asked
for eight running figures, the image model draws three phases and repeats them with the same leg
leading. Oliver, watching the sixth such roll: "the feet look rather like ropeskipping than
running... the legs need to alter in a balanced way". Everything was tried against it and lost: a
figure-by-figure LEFT/RIGHT skeleton spec, a closing "walk the row and name the leading knee"
checklist, an explicit last-against-first loop clause, dropping the count from 8 to 6. _stride.py
CAN swap the legs of one frame (it drew the male's twin at 81% legs / 12% upper on the first roll),
but a pair is a pair, not a cycle.
A VIDEO MODEL HAS NO SUCH PROBLEM, because it moves a body instead of drawing poses. One 5 s
image-to-video clip, ~$0.42 through the OpenRouter key already in .env (POST /api/v1/videos,
async, and the "unsigned" result URL still wants the Authorization header — without it you save a
67-byte 401). assets/_vidroll.py films the clip and cuts it. What the clips taught:
- PIN THE POSE AT BOTH ENDS —
first_frameandlast_frameset to the same still. Her first clip, pinned only at the front, had rotated her into a side profile by the halfway mark. - MEASURE THE STRIDE.
--cutscores every frame against every lag with_famref'slegs/stride_diffand installs N phases spaced evenly across the lowest-scoring period. Her clip strides in 6 frames at 16 fps, his in 23 — the same "every fifth frame" sampling that flattered hers made his read as a stagger. - WHOLE FRAMES, THROUGH
_stride.cut. Raw clip frames are soft, and the chroma key eats lavender-white sneakers unless the test is STRONGLY magenta (r-g > 70 and b-g > 70). The first attempt kept only the clip's legs under his own drawn torso; matching hips frame by frame cost an evening of seams (Oliver: "the upper body moves as well so the 2 parts seem drifting" — the torso has to be placed on each frame's own hips, and even then her skirt defeated it). - THE FIRST FRAME IS THE WHOLE JOB. Generate it as a single still: her identity reference plus HIS approved run frame as the POSE picture ("she is the person; his pose is the instruction") — one roll, no retries, open racket bed. A soft first frame gives a clip whose racket wobbles, which is exactly what his weaker cycle shows.
Settled with it: TUNE.runHz 2.4 -> 1.9 (2.4 on real phases: "this looks like
roadrunner"), the player no longer clamped to two frames — runN probes the sheet through
SK(), so the women's tour counts her six against his six rather than reading his sheet for both
— and the sprite bench grew two PLAYER stops (beats.js BENCH_CHARS), because a player cycle
that is only ever seen mid-rally is a cycle nobody has watched. And bump SPRV: the frames were
right on disk and wrong on screen for an hour because assets/* is cached for a year unless the
?v= moves.
THE RUN CYCLES — one call, one row, one character (2026-08-13)
The p_run/o_run frames were generated ONE AT A TIME in the pre-pipeline era and it showed: the male player's four frames measured 91/110/92/98 px wide, frames 1/3 and 2/4 were near-duplicates drawn differently, and the racket was missing outright. Oliver: "really awkward... I think we need more frames to sell this as good running" — the frame COUNT was never the fault, the drift was.
assets/_runroll.py re-rolled all four characters (player m/f, opponent m/f) on the family-pipeline
discipline: identity from a composed reference of SETTLED frames (_pose_ref_run_<k>.png, tracked,
no API — the broken run frames stay OUT of it), poses from skeleton language in the prompt, one 4K
21:9 call per character, _fine.py cut, stride gate, judged by eye per roll. What it added to the
pipeline's knowledge:
- The model negotiates the COUNT. Asked for 8 it returned 7, then 6, then 8 — and the row it
draws at its own count is the coherent one. The cutter accepts 6..8 and
drawCharprobes run8/run6/run4/run2, so a good 6 beats a fought-for 8. State the count you can SEE with--nwhen the figures touch (a low racket bridges every blob gap). - A run cycle is the one pose set whose generator cliché is RIGHT. No pose reference needed — pin only the frame count, the facing, the racket ("a figure with an empty hand is WRONG", early and loud, after roll 1 dropped the racket from six figures of seven).
- A back view has no direction. Roll 1's "seen from behind, sprinting right" came back running straight away up-court. The working language: back THREE-QUARTER view, body angled toward the right, "NOT a straight-away back view: he moves ACROSS the frame".
- The old male opponent art faced LEFT and had been MOONWALKING right since install (char art faces RIGHT by convention — the gameplay.js note at the swing aim). The new rows are all drawn facing right, which quietly fixed it.
THE TWELVE-RUNG LADDER — four new families, and the pair-reference lesson (2026-08-13)
Oliver: at least 12 opponents. His picks: DIRECTOR, CHAMPION, PHYSIO, MASCOT — all four already
existed as chapter bosses, so each family anchors on its own settled boss frame (z_dir1,
z_champ1, z_physio1, z_mascot1): the rung and the boss are the same person, which is the
roster's narrative. Rolled through _famroll.py (rows added), cut with _zsheet.py, HEAD_CROP
measured for the four. The ladder is now 12 rungs (mascot at 5, physio at 8, director 11,
champion 12) and matchFoe is DECOUPLED from bossArt's cycling — his call, champion caps
rung 12 while chapters keep their 10-rotation. The lists share characters, not arithmetic.
THE PAIR REFERENCE, and when words lose. The champion came back club-pro-slim through FOUR
rolls — two prompt escalations ("shoulders TWICE as wide", "if he is as slim as image 1 it is
WRONG") and a general build-override clause in _famroll.prompt() all lost to the pose sheet's
figures. char_ref now takes a pair: the character pasted NEXT TO a club-pro frame on one
ground line, so the size difference is shown, not described. Even that only half-worked, so the
champion's mass is STATURE instead: OPPONENTS rows carry an optional h (world height, read by
zombieH so the serve aims at the body actually drawn) — champion 2.1 m, the Bulk 2.0 m. When a
build matters, budget rolls for it, and remember the fallback is a data row, not a fifth roll.
THE ZOMBIE FAMILY PIPELINE — one sheet, one character, 31 sprites (2026-08-12)
This replaces the nine-batch flow above for a whole opponent family. _regen.py still exists and
is still right for single sheets, remasters and the boss roster; what it could not do is make a
FAMILY that is one character, because nine separate rolls is nine chances to drift and it took every
one of them — three different brutes, and a corpse that came back as a living man.
The rule the whole thing rests on
ASK FOR THE WHOLE FAMILY IN ONE IMAGE. One roll, one light, one palette, 31 figures. Consistency stops being something you police with gates and becomes a property of the request. This is also what the model's own guidance says: frames generated in bulk as a sheet hold their character; frames generated one at a time do not.
THE POSE REFERENCE IS assets/_pose_ref_family.png, AND IT IS TRACKED
One file is the reference for every zombie tennis character there will ever be. 31 frames of the
club pro in 9 row bands, exactly _zsheet.layout()'s order, on magenta. Every family in ZFAMS
except r itself was drawn off it. It lived in assets/_refcheck/ — which is gitignored — so the
thing the entire pipeline rests on was untracked and unreproducible for as long as it existed. It is
assets/_pose_ref_family.png now, next to _pose_ref_serve.png, and it is committed. It ships in the
image via COPY assets, like every other source sheet; that is not worth a Dockerfile exception.
Nothing in this repo composed it. _regen.ref_sheet() builds ONE row at ONE scale — it is not
what made a 9-row sheet. Whatever did was ad-hoc and is gone. Rebuilding the sheet therefore means
writing the composer INTO the repo, once, with the gate below.
NEVER PUT THE REFERENCE THROUGH THE MODEL
The version of this sheet that produced the six broken families was a GENERATED RE-DRAW of z_r* —
nine separate 4K calls, one per row band, stacked locally. That round trip is where both of Oliver's
sprite-bench faults came from, and _famref.py exists to make it impossible: the sheet is composed
from the sprites on disk, one scale, NEAREST, no API. See "The three defects" below for the numbers.
The four steps
# 1. THE POSE SHEET — composed from the 31 z_r* frames, one scale, NEAREST, gated. No API call.
uv run --with pillow --with numpy python assets/_famref.py
# 2+3. THE CHARACTER (one frame at 4x NEAREST on magenta) and ONE call: poses from image 1,
# character from image 2. Both live in _famroll.py; add a family with one row in its FAMS table.
uv run --with pillow --with requests python assets/_famroll.py <famkey>
# 4. CUT IT BACK DOWN — review first, install second.
uv run --with pillow --with numpy python assets/_zsheet.py assets/_refcheck/fam_<k>.png <k> --out assets/_refcheck/rev_<k>
uv run --with pillow --with numpy python assets/_zsheet.py assets/_refcheck/fam_<k>.png <k> --install
uv run --with … is how these run: this machine's python has no PIL or numpy of its own, and the
previous session's interpreter was a uv ephemeral env. SPLAT_PY still overrides for _regen.py.
Only ink votes: how seven fine families shipped pixelated (2026-08-14)
_fine.block_size() finds the generator's art-pixel grid by scoring b×b blocks for uniformity — and
it scored ALL of them. key() zeroes the background, a zeroed block is uniform at every b, and on a
9-row family sheet the background is ~85% of the figure bounding box, so the background ALONE cleared
the 0.82 bar at any block size tried. Measured on fam_ru.png: 0.0% of ink blocks uniform at b=11,
99.2% of empty ones, overall 0.821 — detected block 11, and _zsheet then honestly snapped 428 px
figures down to 39 art px and scaled them back up. The pixelation was manufactured in the cut; every
sheet the model returned was fine. br survived at 0.775 only because its ink coverage runs four
points higher, and the whole per-family chunkiness ranking (br fine … ch/ru/sh/st at 6 px blocks) was
ink-coverage noise around one threshold. Background-only blocks get no vote now, and all eleven
families were re-cut from the sheets already in _refcheck/ — no new rolls, no tokens.
Two rules fall out of it:
- A detector proven in one geometry is unproven in another.
block_sizewas validated on single-row_regensheets where figures fill the bounding box; the 9-row family sheet silently inverted its ink/background ratio. The threshold never changed — the data under it did. - Diff the artifact against its source before blaming the source. The chunky sprite and the fine
sheet it was cut from sat side by side on disk for two days. One
Readof each would have located the bug immediately; instead the first two theories (legacy_regenprompts, model roll variance) both blamed generation. Oliver's screenshot pair settled it in one glance.
A frozen stride pair in the master blocks the whole pipeline, by design. _famref.py refuses to
write the sheet if any of the three stride pairs has legs that barely move — because the model copies
what it is shown, so a frozen pair in the reference is a frozen pair in every family drawn off it.
Draw the missing twin first:
uv run --with pillow --with numpy --with requests python assets/_stride.py z_rw1 # then z_rw2
uv run --with pillow --with numpy --with requests python assets/_stride.py z_rw2 --example z_rw1
Then: add the key to config.ZFAMS, put fam: '<key>' on an OPPONENTS row, bump SPRV and
main.js?v=, node test.js, and look at it in #bench (arrow keys walk families and wound stages).
The API facts, measured, not read off a blog
Queried from /api/v1/images/models — the endpoint's own capability descriptor:
resolution |
1K / 2K / 4K, default 1K, uppercase K required |
aspect_ratio |
a 10-value enum (1:1 … 21:9). No arbitrary ratios — a sheet gets SNAPPED |
n |
min 1, max 1 |
input_references |
0…14 |
- Use
/api/v1/images, not/chat/completions. Chat completions has no resolution control, so every call silently returns 1K. The same sheet came back 1616x656 through chat and 6336x2688 at"resolution": "4K"— 15x the pixels, for one JSON field. nis 1. Always. "Return both halves" is not a thing the model can do; asked for two, it merges them into one picture and returns that same image twice. Two byte-identical files is the tell.- Pad the input to the aspect you ask for. The ratio is an enum, so a 1:3.2 sheet handed to a 9:16 request gets rearranged to fit. Pad with magenta and the figures stay where they are.
- Multiple references compose ONE output. They are not a batch. Google's sub-limits: <=10 object images, <=5 character images, <=3 style references.
What the model will and will not do
- It cannot output alpha. No Google image model can. "Transparent background" returns white, black, or a painted checkerboard. The chroma key is not a workaround, it is the method — and green is the documented default, which is wrong here because our zombies are green. Magenta stays.
- Its magenta is NOISY: 8,000–22,000 distinct tones per sheet, corners anywhere from (250,1,250)
to (254,3,253). Key a RANGE. An exact
#FF00FFmatch leaves a magenta fringe on every figure. - It draws pixel-art-STYLE illustration, not pixel art — uneven pixel sizes, too many colours,
and it adds anti-aliasing and gradients unless forbidden. Prohibitions help and do not solve it.
_fine.py's block-snap + box-filter + palette quantise is what makes it read as sprite art; the softness does NOT wash out in the downscale alone (measured againstz_g1, it stays visibly smoother). - Name each image's JOB in the prompt. "IMAGE 1 is the layout and poses only; IMAGE 2 is the
ONLY source for who the character is." Without that the majority wins:
racket_play's reference is one zombie and three living humans, and a re-roll came back as a living man despite the prompt carrying the CORPSE rule verbatim. A picture beats a paragraph — including when you did not mean it to. - Frame it as an EDIT, not a composition, and tell it to match image 1's drawing style. Every "draw a new sheet" roll came back painterly; the edits kept the technique.
_zsheet.py, and the two things that must be GLOBAL
It reuses _fine.py's keyer, art-block detection and column cutter, adds row-band splitting, and
fixes the two things that break a family if they are done per-row:
- SCALE — taken once, from the play row's standing frame.
_fine.pyanchors one scale per sheet, so nine bands would each normalise to their own tallest figure and the serve strike would come out the same height as the idle stance. The game sizes every sprite off its own PNG height, so that is a body that pops. Measured after: serve 319, prone fall 83 — the racket family's own are 307 and 85, arrived at independently. - PALETTE — taken once, from the largest figure anywhere, so a wound row cannot quantise onto a slightly different set of colours than the row above it.
And it INVERTS _fine.py's enclosed-magenta rule, deliberately. _fine.py repaints magenta
trapped inside a figure as pale grey, because the generator used to fill racket string beds with the
backdrop colour BY ACCIDENT. Here the prompt asks for exactly that and gets it, so the bed cuts to
transparency and the racket is an open hoop. That is only safe because nothing else on the figure is
magenta — check a new sheet.
Three fixes this found in _regen.py, which still apply to it
- A margin is a PROPORTION, never a pixel count. "At least 150 pixels of empty magenta at every edge" means nothing to a model that picks its own output size; it composed at 92% of a canvas it chose and clipped 3 rolls in 4. Stated as a fraction, edge rejections went to 1 in 3.
- A reference sheet must not show a racket as a magenta hole. Older sprites were cut before
_fine.pylearned to keep enclosed background, so their string beds are transparent —o_smashhas 3,425 such pixels — and pasted on the magenta canvas they came back as magenta. Four of the racket family's nine references were showing the generator exactly what the prompt forbade.ref_sheetfills them with a woven pale bed now. --verifyasks the question no gate here ever asked: is the art already on disk what it claims to be? Every other check fires on a NEW roll and says nothing about installed frames — and a chained batch draws its reference from installed frames. It found 27 of the brute's 31 dead:z_brw1fallwas ONE DRAWING PASTED FOUR TIMES (0.011–0.055 apart, where a real fall sequence is 0.53–0.89). Run it before trusting any chained batch.
The three defects the sprite bench found, and the fix (2026-08-12, ALL FIXED)
Oliver walked the sprite bench and reported the same two faults on every family: the healthy walk
loses its head, and the wounded walk's feet do not move. All of it traced to _pose_ref_family.png,
and the pipeline had copied it faithfully — which is the pipeline working. Measured per figure as
the implied scale of the sheet against the z_r* PNG it was pasted from, width and height separately:
band 0 play 1 w2.339 h2.362 | 2 w2.341 h1.996 <-- the ONLY non-proportional figure in all 31
3 w2.345 h2.360 | 4 w2.333 h2.361
band 1 serve 2.335 | band 2 fall 1.902 | band 3 w1 walk 2.34 | band 4 w1 serve 2.30
band 5 w1 fall 1.922 | band 6 w2 walk 2.38 | band 7 w2 serve 2.047 | band 8 w2 fall 1.945
1. z_r2 IS PASTED WITH ITS HEAD CLIPPED OFF. Width scales at 2.341, height at 1.996 — 33 sprite
px missing off the top, width untouched. Every other figure is proportional to three decimals, so
this is a clip and it is the only one in the sheet. z_r2.png on disk is fine (123x225, head
present): the damage is in the composition, not the sprite. One bad paste, six families — and this is
the whole of "healthy walk loses its head", because drawZombie picks frames 2/3 as the walk pair.
The last commit's walk-frame levelling makes it WORSE, and that is not the levelling being wrong: it
reads the missing head as a height error and scales the body up to compensate, so z_sh2 is 147 px
wide against z_sh3's 116. The walk flickers between a normal body and an oversized headless one.
2. THE SHEET IS NOT ONE SCALE, IT IS FIVE. The fall bands sit at 81% of the standing bands and the
w2 serve band at 87%. That is the exact bug _zsheet.py was written to prevent on the way DOWN,
present on the way UP — the sheet tells the model a falling body is a fifth smaller than a standing
one. _zsheet.py's fall1 re-anchor and the walk levelling exist only to undo this. Rebuild the
sheet at one global scale and both should become no-ops; that is the check that they were
compensations and not fudges.
3. THE WOUNDED STRIDE PAIR WAS NEVER DRAWN, and a rebuild will not fix it. Leg-region silhouette difference within the stride pair (feet-anchored, mass-centred, bottom third):
| healthy 2 <-> 3 | w1 <-> w1b | w2 <-> w2b | |
|---|---|---|---|
| the reference sheet | 51.1% | 14.3% | 14.5% |
installed r |
51.0% | 15.2% | 14.8% |
| the six new families | 41–70% | 4.8–19.7% | 6.2–19.9% |
z_rw1b genuinely has the same legs as z_rw1 on disk, so the club pro has been sliding through both
wound stages since the day he shipped — r IS the reference, which is why its numbers match it
exactly. drawZombie is animating correctly (verified by instrumenting drawImage on the bench: it
alternates z_rw1 <-> z_rw1b every 0.42 m of ground covered, the right frames at the right
cadence). This one needs the master's w1b/w2b REDRAWN with the legs swapped before a rebuild is
worth spending a roll on.
THE GATE THAT WAS MISSING IS ON THE INPUT. _zsheet.py checks the roll that comes back — band
count, figure count, stacked bodies, edges, palette, heights — and nothing had ever checked the sheet
handed TO the model. _famref.check() is that gate: it re-reads the sheet it just wrote with
_zsheet's own band and figure detection, and refuses one where any figure's width and height scales
disagree by more than 2% (the head clip was a 0.35 discrepancy) or where a stride pair is frozen.
How each one was fixed
| defect | fix | cost |
|---|---|---|
| 1 · head | _famref.py composes the sheet from the sprites on disk, one scale, NEAREST |
no API call |
| 2 · five scales | same — one k for all 31 figures, derived once |
no API call |
| 3 · frozen wounded stride | _stride.py draws the missing twin, shown the healthy pair as the example |
2 rolls |
Defect 3 is the one that needed new art, and the thing that made it work after every previous attempt failed was showing the edit instead of describing it: IMAGE 2 is the healthy stride pair side by side, the same character performing exactly the change being asked for. Words alone ("identical above the hips, legs clearly swapped") had returned near-identical drawings for as long as they were tried. The headless twin then took the ARM-OFF pair as its example rather than the healthy one — a wounded body is a closer picture of the edit, the same chaining the wound ladder itself uses.
_stride.py's gate measures three things and prints all three: legs moved (>=30%), body above the
hips did NOT (<=25%), and the racket bed survived (>=0.6x the base's area). Its thresholds are
calibrated on stride pairs that already ship, not chosen — the table is in the file. My first
guesses, 35 and 14, rejected z_e1/z_e2 and z_g1/z_g2, both of which are accepted art.
And a stride twin FILLS the racket bed instead of keying it out — the opposite of _zsheet.py,
for the opposite reason. _zsheet cuts a whole family, so every bed keys out together and the racket
is an open hoop throughout. A twin is half of a pair whose other half is already on disk with a
painted bed: the first rolls came back with 959 px of enclosed transparency against their base's 0,
which would have flickered the racket face empty/filled once per step.
After the fix, measured on the installed sprites
Every family re-rolled off the rebuilt reference. Leg difference within each stride pair:
| fam | walk 2/3 | w1/w1b | w2/w2b | fam | walk 2/3 | w1/w1b | w2/w2b | |
|---|---|---|---|---|---|---|---|---|
| r | 51.1% | 68.1% | 36.7% | ru | 55.9% | 59.1% | 38.7% | |
| br | 40.7% | 42.0% | 26.8% | gk | 41.1% | 44.6% | 30.6% | |
| sh | 59.4% | 71.4% | 43.9% | lj | 38.4% | 45.5% | 27.4% | |
| bb | 56.5% | 67.1% | 43.2% | st | 51.6% | 66.1% | 35.7% | |
| was 4.8–19.7% |
Every play row has its head, verified frame by frame in #bench, and all 24 family/wound-stage states
alternate a real pair (checked by instrumenting drawImage on the bench, not by eye). br and lj
sit at 26.8 / 27.4 on the headless pair — the hardest one, subtler than the rest but visibly moving;
worth a re-roll if they ever read as stiff. lj needed one re-roll: its first came back frozen on all
three pairs with the headless row 31 px short.
Still open
- The style is close but not identical to the hand-made sprites: softer outline, blended shading rather than dithering. Oliver accepted it ("I like the style") on the grounds that a RELIABLE way to generate art is worth more right now than an exact match.
- The 9:16 padding spends most of the 4K budget on empty magenta — the figures came back at 243x288 before downscaling. A layout that fills the canvas would give each figure far more real pixels.
Session log: Oliver's playtest of the barrel weapons (2026-08-03, same day)
Everything below is a fix to what shipped that morning. All four notes were real defects, which is now seven playtests in a row where "this feels odd" was a bug and not taste.
"The bigger guys were way too easy to kill at the beginning." It was a bug, and an old one. The
ball-vs-body chain handled boss, g/m, s, f, a/b — and everything not named in it fell
through to "splat-quality kills outright". So the STEWARD has been dying to one serve since the day
he shipped with stewardHp: 5 and a comment calling it "a brute's worth of hp", and the ball boy,
bouncer and roller man inherited that the moment they existed. Pellets and melee always read hp;
only the BALL skipped it. It is one generic hpMax > 1 branch now — hp is the only thing that says
how many hits a body takes — and measured after: shambler 2, steward 5, brute 5, ball boy 3,
bouncer 5, roller man 7, each exactly its hp. Anything that adds a body type must NOT need a new
branch here; if you find yourself writing one, that is the bug coming back.
"I have seen all of them by round 10." The new three moved 3/8/15 -> 5/12/20. The original four keep their gates exactly: the curve through ~game 12 was already tuned and the calibration rule says not to touch it. Their hp came down with the fix landing (9 was a fine number while nobody felt it and a long grind once they did).
"The laser fired only when I tapped, that was confusing." The whole kind: 'shot' idea is gone.
A barrel weapon is now exactly one of two things, and this is the rule to hold: either it is the
normal ball gesture made spectacular, or it fires itself for a while. Nothing waits for a tap.
- laser is a
servenow: pull, aim, release. The RAY is the weapon, so there is no ball anywhere in it — the preview is the WEDGE itself (drawBeamWedgeat low alpha, not a ballistic band with a target ring), the tap path has its owntryLaserso it never tosses a ball it is not going to hit, andfireBeamkills everything inside the cone (beamW00.35 m at the racket tobeamW14.2 m at the far end). Measured: 8 of 11 bodies in one release. Three things Oliver corrected on the second look, all of them "it must read as light he fired": it leaves the RACKET (dragContact(), y 2.2, settling onto the court as it goes out — drawn flat from his feet it read as a shape lying on the grass); the release ERASES it along the path (the plane is whole at the flash and is wiped away from the racket outward behind a bright front, so the shot reads as energy travelling down the lane rather than a decal fading where it lay); and the edges are hairlines — at 2.5 px they were heavier than anything else on the court and read as a drawn outline instead of as light. Gotcha for testing:TUNE.pullMinis 18 PIXELS, andaim.fyis NEGATIVE for the down-pull. A synthetic{fx, fy, mag: 0.8}is silently below the threshold andpreviewServereturns null, which looks exactly like the preview being broken. - beacon and rocket are
burstand LOBBED —arcon the WEAPONS row turns a flat pellet into a thrown one (solved onto the nearest body ahead), and landing is itself a hit, so a rocket that misses still goes off where it lands. The beacon changes colour every ball and drags that colour behind it. - flamer got "way more pronounced": 30 rounds/s, a ~10 m cone, fat glowing rounds with embers.
The pale blue washed out to grey over the lawn — a beam colour has to be saturated (
#3f74ff), which is the same lesson as the barrel's halo, from the opposite direction.
"I got the laser almost every time." A flat 1-in-4 over four items does not feel uniform. It is
weighted and gated now (BARREL_WEAPONS carries weight and from), never repeats twice running,
and the beacon is what you meet first. Measured: laser 16-28% depending on the game, rocket unlocked
at game 12 so it is genuinely a late find.
The barrel's pink is procedural and can be as pink as it likes — Oliver asked whether it could
still have a pink halo given the key eats magenta out of PNGs, and yes: drawBarrel paints it at
draw time and never goes near the keyer. Three rings plus a pool on the lawn now, because one flat
disc read as a smudge.
Session log: a rack you fill yourself, and barrels that fill it (2026-08-03)
The second half of the same ask. The rack no longer has fixed slots. It used to be five hardcoded
positions where the index WAS the weapon — SLOTS, slotStock, WDESC, drawRack's three parallel
arrays, deliverTin's w + 1, and bare wsel === 1|2|3 / G.weapon === 4 tests in five files. Now
config.WEAPONS is the single table, G.rack holds up to four {id, n} in the order you GATHERED
them, and slot 0 is BALL. Read it through slotId / slotStock / slotEntry and nothing else needs
to know how it is stored.
A fifth weapon is always allowed (Oliver's rule): it rides slot 0 as G.temp until spent, then
slot 0 goes back to being the infinite ball. A barrel arms on the spot and is spent quickly, so
refusing it would mean a barrel you shot did nothing. A spent weapon LEAVES its slot — with
gathered slots, an exhausted weapon sitting in one is a quarter of the rack you can never pick up
into. (The old rack kept empty slots dimmed, which was right when a slot meant "BOMB goes here".)
Four new weapons, and they are all the one pellet loop. firePellet(w) stamps the WEAPONS row
onto the round — r, dmg, hp, pierce, boom, ignite, col, speed, life — so the beacon
cannon, flamethrower, laser and rocket needed no new array and no new update. The break after the
first body was the only thing making a pellet single-target. Every one of them goes through the same
isBoss branch, which is why none can silently no-op on a boss the way three of five once did.
Measured: gun/beacon/laser/rocket reach 16 m+, the flamer is a deliberate ~6 m cone that ignites.
G.mgT stayed the mode timer for all of them, which is why the eighteen !(G.mgT > 0) guards
across five files did not have to change. kind:'burst' auto-fires at its own rate for len
seconds; kind:'shot' (laser 10, rocket 3) holds the same braced stance and fires one per TAP.
The barrels are the ball machine's shape reused: a rolling, shootable non-body. Three
registrations are silent if forgotten and all three are done — blocksClear (a barrel must NOT gate
the hand-clear; it is optional loot), the contact-kill guard (furniture cannot eat you), and
hitR/GORE_MUL/zombieH. It scores nothing.
The pink is procedural, and it has to be — see pipeline note 15. A magenta drum and a magenta laser pistol were both eaten by the chroma key.
TUNE.tinTriples 3 -> 10, as asked.
Session log: the roster grows — moving bosses, six of them, three new bodies (2026-08-03)
Oliver asked for five things. This is the first half: enemies first, weapons second, his call, so the arsenal could be tuned against the harder game rather than the other way round.
Bosses move now, and the style is data. A boss previously matched NO branch in the variant chain
and fell through to the crowd's aimless ±0.15 m/s wobble — "the boss AI owns its x" was a comment
describing code that did not exist. Six styles, one per design: pace (groundskeeper, and the coach
at 1.9x), shadow (line judge — paces a band centred on YOU), surge (chair umpire — rolls up and
down the whole depth band), charge (physio), bounce (mascot). Measured over a simulated minute
each: 50–136 m travelled, none static. Three earlier drafts of these styles failed the same way and
it is worth knowing why: they were written as "walk toward the player's x", which in splatter means
arrive in four seconds and stand still for the rest of the fight, because the player is rooted in
pretoss and never moves. Measure xSpan over 3600 steps before believing any movement change.
Three new bosses: THE HEAD COACH (rapid 5-shot drill, 0.14 s apart), THE PHYSIO (charges — it
withdraws LESS than it charges, so it is a couple of metres nearer after each rush and reaches you at
about 38 s, which is the fight's clock), THE SPONSOR MASCOT (a gap: 0 volley — the whole wall of
five leaves in ONE frame, so it is dodged, not parried; the salvo loop is a while for exactly this).
Harder to kill: bossHp 16 -> 20, bossHpPerChapter 9 -> 10, plus per-design hpMul.
Chapter 6 is now 91 hp — the number most likely to want tuning after a real playtest.
Three new bodies: ball boy 'e' (3 hp, 2 skins, from game 3), bouncer 'h' (6/3, game 8), roller
man 'k' (9/4, game 15). Named "roller man" and not "groundsman" so he cannot be confused with the
GROUNDSKEEPER boss.
One real bug found, and it was pre-existing. killPlayer() empties shots[] while the shots loop
is mid-iteration, so every remaining index reads undefined and the frame throws. The mascot's
five-at-once volley makes it easy to hit; the shipped 3-shot volley could always have hit it. Fixed
with the same if (!s) continue guard the body loop above it already carries, and for the same reason.
Verification that actually caught things (all via window.T, RAF halted):
drawImageinstrumentation per design: all six bosses and all three new bodies draw their OWN frames, both…w/…wbstride twins included. Nothing silently falls back to design 1.- Differential check on the refactor: the brute still draws
z_gw/gw2/gw3/gw4at hp 4/3/2/1 — the new sharedwoundStageis byte-identical to the array it replaced. - Foot-gap spread: ball boy 76% (the shipped shambler is 36%, the brute 39%). Boss stride-pair difference 34–54%, inside the shipped range of 30–83%.
- 26-game soak: no exceptions, no stall,
mgTback to 0, gore capped 40, piles capped 30, ande/h/k/Ball appeared in it.
Session log: court types (2026-08-01)
Three courts, picked on a second start-screen stage. A court is pure DATA — COURTS in config.js
— so adding one is a table row plus four PNGs, never a change to render().
- Each court carries TWO palettes, not one. Every court still rots when the horde arrives, so
the row holds a
tennispalette and a drainedsplatterone andpalette()lerps between them exactly as before. A single palette plus a global rot filter was the tempting shortcut and it is wrong: what "drained" means is different on orange clay than on green lawn. - Mow stripes are a flag, not a colour.
court.stripes. Recolouring the lawn to terracotta and leaving the bands on still read as grass — the banding is the grass tell, not the hue. - Physics is two numbers copied into TUNE on selection (
selectCourtin state.js). Grass skids low and fast (0.50/0.84), clay sits up and drags (0.66/0.70), hard is the old baseline (0.58/0.78). Doing it this way means the three bounce call sites in gameplay.js never learn that courts exist, and the T panel still edits the live values. - A court's clap frame must come from its own sheet.
x_crowd/x_crowd2are a matched pair — same people, arms up. Pairing court A's seated sheet with court B's clap sheet cross-fades between two different crowds, which is the flicker class this codebase keeps re-learning. The clay/hard clap sheets were generated FROM their own seated sheet ("same image, arms raised"), not fromx_crowd2, for exactly this reason. - Ad-strip aspect is load-bearing.
render()draws the sheet at a fixedAD_Hof 14 px and derives tile width from the sheet's own aspect, so a strip that comes back 6:1 instead of 9:1 renders its lettering at half size and goes unreadable. The first clay roll was 6.34:1 and was thrown away; re-rolling withx_ads_h.pngas the input (already 9.27:1) fixed it. Check the aspect of a regenerated ad strip before installing it. - The picker is DOM, not canvas — a second panel inside the existing
#introoverlay, rows built fromCOURTSin main.js. Same Silkscreen card, no newG.mode, no canvas UI code. Last pick is remembered inlocalStorageand comes up pre-highlighted; keys 1..N pick, Enter takes the highlighted one.
Not done: clay ball-marks (the gore decal loop already draws flat ground ellipses, so a pale
scuff per bounce is one call), per-court bounce SOUND, per-court crowd density.
Menu theme (background-music.wav, menuMusic/menuDuck in audio.js). Started at BOOT so the
title screen has music, ducked out when a court is picked, faded back in at EATEN_SUMMARY — the
death summary is the end menu, and the zombie bed ducks out as it comes in. Two things this cost:
loopBednow callsaudio()BEFORE its already-started early return. The theme starts before any gesture exists, so on a phone it plays into a context the autoplay policy holds suspended; the call that wakes it is a secondmenuMusic()from the first tap, and that used to return at thebeds.hasguard without ever reaching theAC.resume()insideaudio().fadeBedrecords a duck for a bed that is STILL LOADING (ducked), andloopBedstarts that bed's gain at 0. Without it, a multi-megabyte file ducked mid-fetch comes up at full level several seconds later, over the match.
The overlay is a four-stage machine, not a screen per DOM node: #intro[data-stage] swaps
splash -> {setup, stats, ranks} while the backdrop and card frame stay mounted, because a screen
that unmounts the overlay flashes the live court through between menus. The title screen IS the
menu — a separate menu stage behind it was the same logo on the same backdrop with two buttons,
and read as the title screen failing to advance. Court tiles SELECT only; SERVE UP commits.
THE TUTORIAL IS GONE (was TUT in main.js, eight pages of Marvin). What it explained is already
taught in-game by the first-time-only modals, and its stage is the stats screen now. Deleted with
it: tut_*.png, marvin1-4.png, btn_tut.png, btn_start.png, and the marvin regen batch.
x_howto.png and x_cross.png LOOK tutorial-ish and are not — they are in-game modal art.
Every menu screen is three bands: something pinned to the top, something pinned to the bottom,
and the slack in between. .card is a full-height flex column, .foot uses margin-top:auto (drop
to the bottom of a short screen) plus position:sticky (stay there on a long one), and #intro is
the scrollport those stickies resolve against. align-content is safe center, never
place-items: center — plain centring on a grid whose item overflows pushes the item's top out of
the scrollport where nothing can reach it. Two registers, unchanged: Bangers shouts (screen
titles, the crimson primary button), Silkscreen states (every number, every label).
The court cards' SPEED and BOUNCE meters are derived from COURTS[].bounceXZ / .bounceY, not
from hand-written ratings — so a physics change moves the bars and the screen can never advertise a
court that plays differently from what it says.
DEATH NO LONGER RELOADS THE PAGE. location.reload() was an honest full reset and for a long
time the right call — but it also tore down the AudioContext, so the menu theme restarted from
silence every time you died, when it is supposed to carry straight on from the death summary into
the title screen. gameplay.restartRun() replaces it:
state.resetState()restoresG,P,O,ballandscorefrom astructuredClonetaken at module load. Everything in those bags is plain data, so the clone is the whole job — nothing has to enumerate fields, and a field added later is covered for free.- The two modules with timers of their own put them back themselves:
zombies.resetZombies()(every array plusspawnT/moanT/mgTick) andinput.resetInput()— a key still held or a half-finished drag would otherwise be inherited by the next run's first frame. - The overlay is hidden, not removed, so it can come straight back.
#intro[hidden]needs its own rule because thedisplay: gridon#introwould otherwise beat the hidden attribute.
If you add module-level mutable state anywhere, it belongs in one of those reset functions.
stats.js is the only thing that remembers anything. One localStorage key, splatterRuns: an
array of per-run records ({t, g, s, d, k, mk, dead, z, w}), newest last, capped at 300. ALL TIME /
LAST MATCH / 7 DAYS are three filters over that one array, not three stored counters — a fourth
period is a filter, not a migration. It replaced splatterBest, which it migrates on first read.
- The live run is module-local, not a field on
G.Gis deep-cloned intoPRISTINEat load and restored wholesale byrestartRun(); a counter living there would be reset by machinery that knows nothing about stats. killZombietakes an index, not a cause. Eleven call sites reach it, so instead of threading a weapon id through all of them (and throughwound(), and throughsplatter()), every damage loop in zombies.js stamps a module-levelCAUSEbefore it can kill. Safe because a frame is one synchronous pass. The trap: a kill path that fires OUTSIDE a stamped loop inherits a stale cause — the flame ball's fly-pastignite()did exactly that and filed 'fire' kills under 'ball'. Burning bodies carryzb.burnByfor the same reason: they die long after the thing that lit them.endRunneeds an explicitbankedflag, not an "is the bag empty" inference. The games count lives onGand survives the flush, so a second call sails past a nothing-happened guard and writes a duplicate with zero kills.pagehidereally does fire more than once (bfcache dismiss and restore), which is how this was caught.tally()clears the flag, so a fresh run re-arms itself with no reset call for anyone to forget.T.stats.check()is the runnable check: it asserts the period filter and the aggregation against a fixture, andread()takes an injected array precisely so it never touches a real save.
THE DEADLOCK WATCHDOG (watchdog() in gameplay.js) exists because a run that hard-locks costs
the player everything, and this file has form: mgT was once left above zero by a beat that moved
the phase underneath it, and mgT > 0 blocks serving. It does NOT time how long the player has been
blocked — a horde blocks you for fifteen honest seconds. It watches whether the CLOCK that should
release you is moving: every legitimate wait (horde, burst, changeover, duel intro, an 'over' beat)
counts down, so a frozen signature for TUNE.stuckT seconds means the state is dead. It logs the
frozen signature into the miss log (scoreboard tap) before letting go — a lock that unsticks
itself and leaves no evidence is worse than one that does not. Soaked five game-minutes to game 23
through a horde: zero firings.
- Anything new that blocks serving must either tick a clock or be added to
watchdog's release. - The matching root-cause fix is in input.js:
visibilitychange/blur/pagehidedrop the in-flight gesture.stickandG.aimare only cleared by the touchend for the matching identifier, and iOS does not guarantee that event when you switch apps mid-drag — a heldG.aimthen blocks the uncaught-toss retoss (!G.aim) forever.
A won duel must always pay. deliverTin walks the four-tin ladder, and when every rung is at
wCap — which is the state from about game 20 — it now falls through to the barrel weapons
(unlocked ones only, so no rocket in game three). Before that it printed RACK FULL and handed over
nothing, which is the one thing the duel exists for. RACK FULL now means literally everything is
capped.
The tin gets a CARD, not a flash. G.flash is the praise channel (PERFECT, DOUBLE KILL), so a
weapon arriving there read as another compliment. G.card takes an optional icon now and draws it
above the title; the card does not wait for a tap. weaponModal still fires first-sight-only,
because that one has rules to teach.
The world board ranks KILLS IN A SINGLE RUN. Games survived is the same information at a tenth
the resolution — most players land between 5 and 20, so a games board is a wall of ties broken by
who got there first. Games ride along as the tiebreak and as a column. The prompt to sign it appears
on exactly one occasion: a run that beat your own best kill count, on the tap that leaves the death
screen. stats.lastRun() carries it across restartRun() — it lives in the stats module, not on
G, which has just been restored from PRISTINE, and it is consumed on read so it asks once.
- A name belongs to whoever took it first. The client mints a random
splatterIdonce and keeps it; the row remembers it; a submission under an existing name with a different id is a 409. That is the entire security model and it is enough: a stranger cannot overwrite your row. The id must never be sent to a client —public()strips it, and a leak would hand anyone the name. Clear your browser storage and you lose the name, which is what having no accounts costs. - One row per name, their best. The client only posts on a personal best, but its idea of "best" is per-device — the same player on a second phone starts from zero and posts worse runs, so a weaker submission is dropped SERVER-side rather than merely sorted below.
- Names come back from the wire and are written by other people.
clean()whitelists them to[A-Z0-9 ._-]andesc()escapes them again on the way intoinnerHTML; keep both. - The client's
tidyName()mirrorsclean()exactly. When it did not, the server storedOLIVER, the client rememberedoliver, and the board silently failed to highlight the row you just wrote.
Menu art is generated DIRECTLY, not through _regen.py. That pipeline keys magenta
and slices figures out of a sheet; these are whole illustrated panels with backgrounds, so they go
through generate_image.py and a downscale/quantise pass, the same route the court tiles took. Two
things learned making them:
assets/menu_bg.png(the splash backdrop) was made by EDITINGcourt_grass.png, not from a text prompt:generate_image.py "<the changes>" --input assets/court_grass.png. Edit mode holds the camera, the palette and the line weight for free, and asking for a portrait aspect in the same prompt worked. Then halve it exactly (//2, so the generator's ~4px art-pixel grid stays square — a non-integer divisor shreds this art) and quantise at 128. 768x1376 -> 384x688, 80 KB.- No python3 on this machine has Pillow. Run the downscale/quantise step as
~/.local/bin/uv run --with pillow python - <<'PY'.generate_image.pyitself needsrequests, which/opt/homebrew/bin/python3.11has. - Quantise these at 128 colours, not 64. The panels are big flat fields with a few saturated accents (the ball, fire, blood), which is exactly where a small palette shows — 64 turned the tennis ball beige.
- Find a generated plate by its BRIGHT BORDER, never by the corner colour. One button plate came back on a near-black margin and the other on a transparency CHECKERBOARD, which differs from itself everywhere and matched the entire canvas. The threshold has to adapt per image too: the checkerboard's own greys are brighter than the darker plate's border.
The button plates are blank on purpose — the lettering stays Silkscreen on top of the art.
Generated type is a lottery and would not match the rest of the HUD. btn_start is a BOTTOM band of
a taller plate: by there the drips have run out to the ends and the centre is clear enough to print
over, and the plate's own bottom border comes with it.
The menu theme starts on the FIRST TAP, never at boot. A title screen that plays at you before
you touch it is rude, and on a phone the autoplay policy would have held it silent until that tap
regardless. loopBed's beds.has guard makes every later menuMusic() a no-op, which is what lets
the title screen pick up the track already playing instead of restarting it.
The theme is AAC (background-music.m4a, 768 KB — the WAV was 8.3 MB, and this one downloads on
every page load before the player has decided to play). loopBed takes the FULL filename now,
because the beds are a mix of .wav and .m4a; fadeBed's key must match it exactly, extension
included, or a duck silently no-ops.
A compressed loop needs an explicit loopEnd. AAC pads the final packet, so Chrome decodes this
48.000 s master to a 48.0174 s buffer — 17 ms of silence spliced in at every seam, once a loop, on
the title screen. menuMusic passes 48. Re-encode the file and re-measure:
fetch(f).then(r=>r.arrayBuffer()).then(a=>new AudioContext().decodeAudioData(a)).then(b=>b.duration).
zombie-mode got the same treatment: 5.9 MB of 24-bit WAV to 287 KB, loopEnd 20.47077 (982597
frames at 48 kHz — an un-round master, and the decoder returns 20.4787, so 7.9 ms of padding is
trimmed). Both beds are AAC now; nothing in assets/audio/ is still a multi-megabyte WAV.
A SPRITE THAT LOOKS "SHRUNK" IS AN ANCHOR BUG, NOT AN ART BUG. _fine.py scales the whole
generated sheet so that --anchor's frame is exactly 224 px, and drawChar then draws every sprite
at img.height / SPRITE_H. So if the anchor frame is one where a PROP goes above the head, 224 px
has to cover the prop too and every body on that sheet comes out a head short. opponent_play was
[o_toss, o_smash, o_swing1, o_swing2] with anchor: 0 — anchored on the TOSS, arm and ball
stretched overhead — which is why the opponent visibly shrank whenever he served or swung
(o_swing1 came out 162 px against a 224 px standing pose). Every batch must lead with a plain
standing figure via ref, which _fine.py anchors on and _regen.py then discards — the shape
the player's own batches already used. Correctly anchored, a toss frame is TALLER than 224 (278 px
here) and the body renders at full size. Quick audit: a standing frame should be ~224, a raised-arm
frame >224, and nothing should be well under it.
EVERY ROLL DRIFTS THE PALETTE, SO THE PIPELINE CHECKS IT NOW. A white headband on six frames,
pale grey shorts on the toss twice, brick-red shirts and brown shoes on the swing — all of it got
past --tries because nothing measured colour. check: "same"/"loose" skips colour_match
entirely, and colour_match is a global mean/sd fit that is far too weak to pull brick-red back to
red anyway (tried it; and a nearest-colour palette lock is worse — it drags the duller reds into the
racket's browns). So _regen.py has kit() and kit_off(), and a batch opts in with
"palette_ref": "o_idle1":
- redness
r - mean(g,b)over saturated warm pixels. Brightness alone does NOT work — a brick-red shirt is exactly as bright as a red one, which is why the first check missed it. - shorts mean brightness of mid-tone neutrals. Charcoal ~61, the pale-grey drift ~83.
- bright% share of the body that is near-white — the shoes and socks. Brown shoes drop it to 0.9%, pale shorts push it to 7.9%; correct frames sit at 2.7-2.9%.
Frames known to be right cluster at redness 99-110 / shorts 60-63 / bright 2.7-2.9, and every
visibly-drifted frame fell outside on at least one axis. Tolerance (12, 10, 1.5). It immediately
rejected five bad rolls in a row and then passed a good one. The palette_ref must be a frame the
batch is NOT regenerating, or it validates against itself.
ref feeds the frames you are REPLACING back into the reference sheet. ref_sheet is built
from [lead] + sprites, so regenerating an already-drifted frame shows the generator that drift and
it faithfully copies it — the swing failed six straight rolls this way, each one re-inheriting the
brick-red shirt from the frame it was about to overwrite. Breaking the loop is one line: copy a
canonical frame over the targets first (cp o_idle1.png o_swing1.png), so the sheet is
colour-correct and the prompt supplies the pose. It passed on the very next roll.
The opponent was running with empty hands. o_run1/o_run2 had no racket in either fist, and
the cause was the batch prompt: opponent_run never said the word "racket" and its reference sheet
was built from the racket-less frames it was replacing, so the generator had nothing to copy one
from. Fixed by making it a ref_only batch off o_idle1 — same man, front-facing, racket
unmistakably in his right hand — with the subject spelling out that he still carries it mid-stride.
When a prop goes missing from a sprite, check the batch's subject before blaming the roll.
Measuring which audio files actually loaded: performance.getEntriesByType('resource') is USELESS
here — the ~200 sprite PNGs blow past the 250-entry resource-timing buffer and every later fetch is
silently dropped from it. Read the network log instead. That cost a wrong "the bed never loaded".
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 serve is THREE poses, not an animation. Waiting: the bounce stance. Drawing the
slingshot:
p_pull, the racket dropping behind him. Release:p_smash, the drive. A four-stage version keyed topullFracwas built, wired late (frame 1 owning the first half) and played, and Oliver cut it back to this — on a phone, at thumb speed, the middle stages are never read. The intermediate frames are deleted, not commented out. - 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.
THE WAY TO GET A POSE RIGHT IS A PHOTO, NOT A PARAGRAPH. Describing a serve in prose and
re-rolling cost about fifteen generations and never landed — the racket changed hands, the four
stages came back near-identical twice, the coil came back as a fold. Oliver's fix: give the
generator the real thing. scratchpad/pose_ref.png is the recipe — his 10-frame photo sequence
cropped to five poses in a row, with two of our own sprites underneath as the character and style
anchor, and a prompt saying which strip means what. One generation returned the whole serve as
our man in our kit, and the frames were sliced straight out of it (anchored on the standing figure
it drew, which is what sets the scale). Note it also redraws the reference strip above the result —
harmless, crop to the magenta band before slicing.
Two 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.
And _fine.py takes --defringe N (default 2). Each de-fringe pass eats a one-pixel line, and
a racket frame IS a one-pixel line: two passes were shredding every racket on the sheet. Detailed
art wants 1 or 0 — there was no magenta halo left at 0 on the serve sheet, because the palette
quantisation snaps to the sheet's own colours anyway.
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
- MODULE-LEVEL STATE CLOCKED OFF
G.idleTMUST SURVIVE THE CLOCK GOING BACK TO ZERO.resetState()rewindsG.idleTfor every new run; anything living in a module's own closure does not rewind with it. The camera flashes had both halves of that bug at once (2026-08-21): aflashNextinherited from the previous run meant NO photographs until the new run's clock climbed past it, and any flash still in flight was left with at0in its own future, which makes1 - (now - t0)/lifepermanently greater than 1 — a shutter frozen at full brightness in the stand for the rest of the session. Oliver: "at a random place in the crowd some frozen midflash circle is visible. after a few points the flashes unfreeze."tickFlashesnow clears and re-arms whenever the clock moves backwards. If you add anything else with a rememberedt0or a "next at" in render.js, give it the same guard — the reset path will not do it for you. - Nothing outside the four slot accessors may know how many slots there are.
endBursthad[0, 1, 2, 3, 4].find(i => slotId(i) === id), which was correct for exactly as long as the rack held five.RACK_MAX4 → 9 and a burst weapon in slot 5..9 was suddenly unfindable, so it was never spent: beacon, flamer, rocket and gun sat at their pickup count forever. UseslotOf(id). The serve weapons were untouched because they all spend throughspendSlot(G.weapon)— the LIVE index, never a search — which is why the laser looked fine while the other four did not. Note also that spending re-indexes the rack (spendSlotsplices), so a slot number goes stale the moment any tin empties. Look weapons up by ID, not by a slot you cached a frame ago. - A modal freezes the WORLD, and
G.flashis part of the world. Its tick used to sit above theif (G.modal) returninupdate(), so any flash raised in the same beat as a popup spent its whole 1.1 s counting down invisibly behind the dim. Harmless while every tin opened a modal of its own; the momentweaponModalbecame first-sight-only, the label flash WAS the announcement and the trainer's popup ate it on every wounded duel win — "later in the game a won duel does not give you weapons anymore". Anything transient and drawn belongs below that return, not above it. deliverTinwalks the ladder, it does not cycle it.TINS[G.deliveries++ % 4]handed you whatever rung came next even when that type was already atTUNE.wCap, wheregrantWeapon'sMath.minclamp made the whole delivery a no-op. Past roughly the thirteenth duel three rungs of four are full, so most late duels paid out in nothing at all. It now advances to the first rung with room, and only saysRACK FULLwhen every one of them genuinely is.- Every sound in audio.js swallows its own errors (
try { ... } catch (e) {}), so a clean console proves NOTHING about whether one played. To verify a sound really built its graph, count nodes — patchAudioContext.prototype.createBufferSource/createOscillator/createBiquadFilterand diff around the event. To verify a PARAMETER reached the synthesis, patchAudioParam.prototype.setValueAtTimeand look for the frequencies you expect (that is how the size scaling onthud/splatwas checked: 150 Hz body thump on a shambler, 100 on a brute, 59 on a chapter-4 boss). - A test that walks the game forward will quietly kill the player. Three separate diagnostic runs
above returned nonsense (
stillCarried: true, no laser fired at all) purely becauseG.modehad become'eaten'several steps earlier and every gameplay entry point bails on that. When a probe says a feature is broken, checkT.G.modebefore believing it.setInterval(() => T.zombies.length = 0, 30)for the duration of the test is the cheap fix. - 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. - The crowd sheet is stretched, not fitted. It is drawn to fill whatever stand band the
viewport leaves, so on portrait a spectator is squeezed to 0.77x his width and in landscape he is
1.36x wide by 0.54x tall — anamorphic, differently per orientation, and nearest-neighbour on top
(one source column in four is simply dropped on a 3x phone, which is the hash on the faces). Fixes
are (a) author the sheet at the aspect portrait actually uses, or (b) tile it at its own aspect the
way
render()already tiles the ad strip. Deliberately not done 2026-08-21: at ~8 logical px per person nobody sees a 25% narrow spectator, and the same effort spent on chroma and silhouette was worth more, and the 2026-08-21 re-roll went out at the same 1408x768 rather than spending the session on a fit nobody can see. Revisit only alongside a re-roll at a chosen size. 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 — still, for two of the six. Designs 1 (GROUNDSKEEPER), 4 (HEAD
COACH), 5 (PHYSIO) and 6 (SPONSOR MASCOT) have 3 wound stages and 4 throw frames each; designs 2
(LINE JUDGE) and 3 (CHAIR UMPIRE) still have 1 wound skin and 2 throw frames. Legal — the
BOSS_ARTindices are clamped — but a chapter-2 boss degrades in one visible step instead of three. Fixing them is now just two batches in the shape ofboss_coach_wounds/boss_coach_throw. Only 3 boss designs, cycling.CLOSED 2026-08-03 — six now, so a chapter does not repeat a design until chapter 7. A seventh is still a sheet plus one row inBOSS_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.
RETIRED IN THE APP, 2026-08-20 —
Info.plistlists one orientation, so the shell never rotates and landscape is officially not-a-surface there. The WEB build can still be turned sideways (iOS Safari has no orientation lock), so this stays open, at a lower stake: it is now a browser-only cosmetic gap rather than something a store reviewer will see. 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, and the 2026-08-21 art pass did NOT spend it — the
three already-turned spectators in the seated sheet are a hint, not the mechanic. Step 6 is: 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 (
- THE LOB ("lupfen") — asked for, built and CUT on 2026-08-22. Do not rebuild it as option
(c). Oliver played the context-armed version and removed it the same hour: "totally
unpredictable." See the session log for the rule that came out of it. What is left open is the
shot with a gesture OF ITS OWN — option (b) below is the only candidate that cannot be confused
with something already in the language. The ballistics are solved and are in the history at
4f4534e; do not re-derive them, and do not re-propose (c).
asked for 2026-08-22, UNDER DISCUSSION, do not build it yet.Oliver: "when you're close to the net in a duel with another player there should be a gesture allowing to make a ball flying high from below... I don't know how to implement this gesture wise — this likely needs to be discussed." The shot itself is nearly free:trySwingalready solves every return throughsolveShotwith a target depth fromSHOT_DEPTH(up = deep, none = mid, down = short), so a lob is a deep target with a much slowersand the net-clearance loop doing the rest — the same maths, different two numbers. The GESTURE is the whole question, and every obvious answer is already spoken for: the flick is the rally aim (up = deep), the pull is the slingshot, a hold is nothing yet but is also invisible. Candidates to put to him, cheapest first: (a) the flick that is already there, but STEEPER THAN THE DEEP ONE — the up-flick keeps meaning "deep" and a near-vertical up-flick means "over him"; costs one threshold and no new input; (b) the same flick, but only ARMED while the opponent is inside his own service box (the shot only exists as an answer to a man at the net, so context can select it instead of the gesture); (c) a two-finger tap or an up-SWIPE from below the ball, both of which are new input surfaces. Whatever wins needs the receiving side to be answerable too, or a lob at the net is an ace: the zombie's chase reads the BOUNCE (aiRead), so a high slow ball gives it time — probably fine, but it must be measured, not assumed.
LATER — shipping it: demo, stores, campaign
Decided in discussion with Oliver, 2026-07-31, extended 2026-08-06, model revised 2026-08-18. None of this is built — the one thing that exists is the domain (item 5). Recorded here so the decisions survive; the ordering below is roughly the order to do it in.
The model (revised 2026-08-18): FREE download, one €2.99 non-consumable IAP that unlocks everything, no ads. Both stores. This supersedes the original "paid $2.99 upfront" decision (2026-07-31) — the whole funnel is short-form video → impulse tap → store page, and an impulse does not survive a price tag on an unknown game; the free tier does the convincing, not the price. €2.99 stays the number (there is no 2,00 € tier; revenue is noise either way per the expectations below). 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. A StoreKit unlock does not break "Data Not Collected", but it does require a Restore Purchases button (no accounts — StoreKit restore IS the reinstall/new-phone path; Apple rejects unlock IAPs without it), and it is the wrapper's first real IAP plumbing.
The free tier (Oliver, 2026-08-18):
- Gore: free through the FIRST CHAPTER BOSS BEATEN, cut on the chapter-2 card — the same cut, same build flag as the web demo (item 1). The free tier must contain everything a promo clip promises (transition, horde, weapons, duel, boss) and must end on a WIN, not a wall.
- Zombie tennis: first 7 opponents, no tournament. Frame the wall diegetically when the tournament frame exists: QUALIFYING is free, "YOU'VE QUALIFIED — the main draw is in the unlock" — the cut lands on a win in tennis language. Check where the six new families sit on the 18-rung ladder; if rungs 1–7 are mostly the base family the free tier undersells the roster.
- Classic tennis: fully free. It is the decoy for the transition and costs nothing.
- Multiplayer: joining free, hosting paid. Friend-code rooms make every paid copy recruit its own opponents — a payer hosting for a free friend is a live demo. (The socket is a staging prototype; this gate waits on LATER item 7 shipping.)
- World board: posting requires the unlock. Free players SEE the board — put the upsell where their row would have been, envy beats a lock icon. With no accounts this is also the cheapest anti-spam gate the board can have. Mind the cold start: a payers-only board is nearly empty at the honest sales estimate; the testteam's rows partially cover it.
- Upsell placement is a feel rule: only on win moments (chapter card, felled card) — never in
the death→restart gap that
EATEN_RESTARTprotects. - Gates do not lower the age rating — the rating questionnaires ask about the binary's maximum
content, gated or not, so gore in the free tier rates the free download (expect Apple 16+, USK/
PEGI 16) regardless. The gates buy conversion, not reach.
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_RESTARTexists 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. Timing (Oliver, 2026-08-18): the web build stays UNCAPPED for now — the testteam runs the PWA. At store launch the public web build takes the same gates as the app's free tier (one flag), or the website is strictly better than the free app and defeats every gate above. -
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): retired for the store build by the portrait lock below (Oliver, 2026-08-18) — locked portrait makes landscape officially not-a-surface, which is cheaper than fixing it for review; the web build keeps whatever landscape does today. What the shell buys GAMEPLAY-wise (2026-08-18) — the countable list of plugins/config the zero-dep rule bends for; only haptics touches game code:- Portrait lock — one config line; see above.
- HAPTICS — the real win, flatly impossible in iOS Safari. Palette v1 is the stock iOS
primitives (three impact weights, three notification patterns, the selection tick) mapped
onto the EXISTING sound-effect call sites — the SFX taxonomy already decided what deserves
punctuation, so the audio map IS the haptic map. One
haptic(kind)helper, no-op on the web. Restraint rule: contact ticks are the metronome, kills punctuate, heavy is rare — a game that buzzes constantly numbs the hand in minutes. Core Haptics custom curves only if the stock palette feels flat. Device-only (the simulator has no Taptic Engine); needs its own phone playtest round before any of it is assumed good. - Edge-gesture deferral — the shell can require two swipes for the home indicator / app switcher, so a bottom-edge flick (exactly where the one-handed thumb lives) stops yanking the player out of the game. The web has no say in this.
- Autoplay allowed in WKWebView — the umpire speaks on first load, no gesture unlock (kills LATER item 6's mute-on-first-load risk inside the app; the in-app-browser web case remains). The iPhone mute switch becomes a choice — games usually play through it.
- Real app storage — stats, run history and the €2.99 unlock state survive; Safari/PWA storage can be evicted under pressure. A tester's career may vanish; a customer's must not.
- Assets in the binary — instant cold start, no
?v=Ndance in the app. Flip side: app updates take store review (days), so iteration stays on the web; the app ships snapshots. - Skipped on purpose: Game Center / Play Games (a second leaderboard dilutes the diegetic world board) and push-notification retention nags (same instinct that rejected interstitials). Both addable later without regret.
-
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. THE CAPTURE RIG IS BUILT (2026-08-18), AND THE SHOT LIST IS DATA.
python3 tools/capture.pyfillspromo/clips/;python3 tools/capture.py horde bossshoots two;python3 tools/capture.py my-shots.jsonshoots another list entirely;--seed 77re-rolls every take. What to shoot lives intools/scenes.json— eleven scenes today — and its"_readme"block IS the schema, written so an agent can open that one file and write a new batch: every key with its range and default, all eight tins and what each does, the five beats, the crowd rosterdiversitywalks, the ten bosses by chapter, and the traps (a serve tin is spent in ~4 s of autopilot fire;movementfights the match's own auto-run legs, so a zombie-tennis scene leaves it 0).tools/clips.jsis the engine that knows how to shoot any of it:tools/capture.pyserves the repo with it injected, opens a throwaway-profile Chrome, and the page stages the scene, records canvas + audio, POSTs the webm back; ffmpeg writes h264/aac. Nothing is faked for the camera — every clip enters through the doors a player uses (T.start,T.intoSplatter,startHorde,startBossFight,startMatch), the crowd walks in where the game's own spawner puts it, and the player walks on held arrow keys through the realaxisX. The two pilots are deliberately mediocre (the tennis one IS the reachability soak's fromtest.js) and are chosen by what the court is doing, not by config:G.mode === 'tennis'taps to swing, splatter draws the slingshot. Six things it learned the hard way, none of them obvious:- The clip is the canvas BACKING STORE, not the window. Chrome opened maximised and ignored
--window-size, andresizeTodid nothing; the frame is pinned instead by giving the canvas a phone-sized CSS box, whichrender.jsmeasures (cv.clientWidth) through its ResizeObserver. - THE SCREEN IS A PHONE, not 9:16 (Oliver, 2026-08-18): 393x852 like a 15-class iPhone, which
records at 1080x2340. The game genuinely lays out differently for it — the portrait branch
derives its logical height from the box — so a 9:16 take is not the framing a player sees.
screen: "9x16"is still there for a platform that insists. TUNE.lowPowerdefaults ON and capsRESat 3, so the first recordings came out 810 wide and needed a blurry 1.33x upscale. The rig sets it to 0; with--force-device-scale-factor=3the backing store IS the output and no filter ever resamples a pixel.- Audio has no master bus — every voice in
audio.jsconnects straight toac.destination— so the rig makesdestinationitself a gain node fanning to the speakers AND to aMediaStreamDestination, patched onBaseAudioContext.prototype(notAudioContext's) before the game module boots. No refactor of the game for a tool that ships to nobody. - The court is remembered, not passed:
main.jsreadslocalStorage.courtonce at module init, so a scene's court rides the URL and is stored before the module boots. - One page load per scene. A fresh boot is the only teardown guaranteed correct, and it costs
a second: the page reloads itself into the next scene and POSTs
/donewhen the list is empty.tools/is in neither the Dockerfile COPY nor the image, andpromo/clips/is git- and docker-ignored: the footage is a local build product, not a deployed asset. Still hand-work after the rig: the name mark on screen (item 5 wants it in EVERY clip), any cutting, and the caption. Skipped deliberately — a burned-in watermark is a taste decision and ffmpegdrawtextneeds a font file the repo does not have in adrawtext-friendly format.
- The clip is the canvas BACKING STORE, not the window. Chrome opened maximised and ignored
-
How a viewer actually reaches the game — the name does the work, not the link. TikTok and Instagram have no clickable caption links, so the bio link costs two taps (profile, then link) and converts in the single-digit percents. YouTube Shorts descriptions and pinned comments DO click, which makes Shorts the best converter of the same upload. But most people who liked the clip click nothing and search later — so the whole funnel rests on whether the name is memorable and resolves. Three consequences, all decisions rather than work:
- the name goes on screen in every clip — not a title card at the start (scrolled past), a small persistent corner mark or an end frame after the payoff;
- the account handle IS the game name (
@splattertennis), which bakes the name into the platform's own UI on every video, permanently, for free — this does more than the bio link; - pin a comment with the name on every video; "what's this called?" is how interest shows up.
The profile page is the real landing page: it should read as a game's page, not a personal one.
The domain is registered:
splattertennis.com(Oliver, 2026-08-06). It matches the searched name, whichsplatter-tennis.wuwi.onenever could — nobody types that from memory. Point it at the demo build and use it as the single link everywhere. The deploy target itself is unchanged (see the top of this file); this is DNS in front of it, plus whatever store links live on the page.
-
Test the game inside the in-app browsers BEFORE the campaign runs. A bio-link tap does not open Safari or Chrome — it opens TikTok's or Instagram's embedded webview, so that is the first-run environment for most players the campaign delivers, and it is the least-tested one this project has. Specifically unknown there: audio autoplay policy (the umpire is the hook — if he is silent on first load the clip's promise breaks), safe-area insets, whether PWA install is offered at all (it is not), and canvas perf at
RES4 — the third place that risk lands, after OPEN 1 and item 2 above.matchMedia('(pointer: coarse)')does still work there, so the mobile branches are fine. -
MULTIPLAYER: 1v1 zombie tennis (decided worth pursuing, 2026-08-12). THE LIVE-SOCKET PROTOTYPE IS BUILT (2026-08-14, staging — see the session log) — Oliver chose the socket over the ghost-match step one. Still open from this item: the ghost match as a solo mode, the wound-decay hook below, reconnect-and-resume. Tennis is one of the friendliest genres in existence for netcode, and this codebase is unusually ready for it — record the decision and the architecture so neither is re-derived:
- The sync unit is THE SHOT, not world state. One ball, one legal hitter at a time
(
ball.lastHitalready enforces alternation), ~one interaction per second, and each flight's ~1 s swallows 100-150 ms of latency before the opponent needs the data. Each client is authoritative for its OWN contacts (your swing window is judged on your phone — lag never eats your tap); a dumb relay forwards shot events (~9 numbers: launch pos/vel/gravity/quality) and point results. No rollback, no shared physics contention. - What already exists toward it: the sim is deterministic-driven (the suite plays the real
modules headless on seeded randomness),
update()steps fixed dt, ball state is one small object,beats.jsis wherestartNetMatch()would live under the court-ownership contract, and the whole serve/swing/volley system tuned for the zombie match IS the netplay input model. - Zero-dep server: python stdlib has no WebSocket, but RFC6455 handshake + framing is ~120
lines of stdlib and
server.pyis already the one process holding state. Rooms = a 4-letter friend code in a dict. No accounts, no matchmaking service. - The honest cost is the last 20%: phone radios (screens lock, apps background, networks switch — reconnect-and-resume is the bulk of the real work), cross-continent mush, and hitter-authoritative trust (a plausibility check server-side is enough for friend codes; do not build ranked-stranger anti-cheat). Prototype = a few focused sessions; robust = weeks of paper cuts.
- STEP ONE IS THE GHOST MATCH, not the socket. Record a run's shot timings + placements (tiny data), serve it over plain HTTP from the existing server, play against the recording — racing-game ghost logic. Zero realtime infrastructure, feels close to a duel, and half the netcode (shot serialization, replay determinism) falls out of it as a byproduct.
- The hook that makes it ours: score-is-the-body applied to BOTH players — every point you lose, YOUR sprite decays a wound stage, so the loser of a long match is a shambling one-armed wreck by match point. Losing IS zombification. Nobody else's tennis game does that.
- The sync unit is THE SHOT, not world state. One ball, one legal hitter at a time
(
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 free+IAP because it feeds the funnel and the gates protect 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.