Oliver on the first shell build: *"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. - **Opaque fetch.** Capacitor serves the bundle through a custom WKURLSchemeHandler, and `fetch` reports those responses as `status: 0, ok: false` with the whole body present and decoding perfectly. All three loads in `audio.js` guarded with `if (!r.ok) throw`, so inside the app every recorded sound failed at the door: the umpire fell through to the synthesized `say()`, no bed started, no sample played. The synthesized sfx kept working, which made it read as a mixing bug rather than a loader one. Fixed once, in the shared `bytes()` helper every audio fetch now routes through — a status of 0 is the ABSENCE of an HTTP status, so trust it only when there is one and a real 404 still falls back. Any future `!r.ok` on a bundle-relative fetch is this same bug. - **The ring switch.** WKWebView's WebAudio runs in the SoloAmbient session, which the hardware silent switch mutes outright — a silent game on a phone that lives on silent, with nothing on screen to explain it. AppDelegate now sets AVAudioSession `.playback`, the games category. Confirmed in the shell: the bed reaches PLAYING, the context is `running` with no gesture at all (autoplay really is allowed in WKWebView, as LATER predicted), and `say()` never fires. On the web, a genuinely missing file still falls back — checked both. And the swipe flips: **right goes back**, the way iOS's own pop gesture goes (*"that's the iOS way"*). Left now does nothing. Noted in PROJECT.md that on a platform gesture the platform's convention beats the literal wording of the brief, and that is worth saying up front. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4248 lines
327 KiB
Markdown
4248 lines
327 KiB
Markdown
# 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-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_start` name `splatter-tennis` (`server.py` on :8641, `.claude/launch.json`).
|
||
It is a real server now, not `http.server` — **editing `server.py` needs 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. **Bump `main.js?v=N` in index.html every time** or phones keep the old build.
|
||
The Dockerfile must also COPY any new top-level file (it currently ships `index.html`,
|
||
`manifest.webmanifest`, `server.py`, `src/`, `assets/` — `test.js` only feeds the test stage and
|
||
is deliberately not in the served image).
|
||
- **The image is `python:3.12-alpine` running `server.py`, no longer `nginx: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, and `application/manifest+json`, without which
|
||
Chrome refuses to install the homescreen app) is a few lines in `server.py`. Standard library
|
||
only — no pip, no requirements file.
|
||
- **`/data` MUST 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 `staging` branch deploys to its own subdomain (own Coolify resource, same repo,
|
||
branch `staging`, same Dockerfile). Test there first, then merge to `main` for 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-filtered `main` and `staging`, 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.modal` is the only thing that stops the world.** `update()` early-returns on it (after
|
||
flash/card decay), so no zombie steps, no ball flight, no spawn timers — only `idleT` ticks for
|
||
the blink. `showModal`/`dismissModal` in gameplay.js; `drawModal` is the LAST thing render draws.
|
||
Dismissal must **swallow the gesture**: input.js `touchstart` returns on it, keydown returns on
|
||
it, and the `pointerdown` dismisser is registered **only on `pointer: fine`** — on touch,
|
||
pointerdown fires *before* touchstart, so a shared handler cleared the modal and let the
|
||
touchstart underneath serve. `G.seen.<key>` gates first-time-only modals.
|
||
**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 over `TUNE.modalIn`) is the arcade entrance** and the ONE thing that animates behind
|
||
the freeze — it is ticked inside that same early-return, so it advances while nothing else does.
|
||
Icon and title fly in from the right (with a two-ghost speed streak), body lines cascade from the
|
||
left, all on `easeOutBack`; every x is `Math.round`ed, because a fractional x on a nearest-neighbour
|
||
scaled image re-snaps its pixel columns (the boiling that once read as HUD flicker). A modal with no
|
||
`in` field draws fully landed, so older/test call sites still work.
|
||
**`m.hold` is an optional self-dismiss clock** (seconds, ticked only once `m.in` has 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 no `hold` and waits for the player, as before.
|
||
While `m.in < 1`, `dismissModal` **returns true without dismissing** — it swallows the gesture but
|
||
won't let a panic tap that was already mid-air skip a popup nobody has read. Returning false there
|
||
would drop that tap through into a serve instead.
|
||
- **The difficulty director lives in `updateZombies`'s spawn block + `spawnZombie` + `pickVariant`,
|
||
and every dial is keyed on `G.runGames`.** It is the thing that broke first: as shipped, ALL of it
|
||
bottomed out by about game 12 (body cap pinned at 14, spawn gap floored at 1.0 s, variant mix frozen
|
||
at exactly 0.42 advanced, shamble speed maxed after 105 *seconds* of splatter, boss stuck at 3 hp,
|
||
every horde identical). Oliver played 50+ games and reported the plateau; games 12-50 were
|
||
arithmetically the same game. All six now keep climbing — see the TUNE block marked
|
||
THE DIFFICULTY DIRECTOR. Calibration rule: the curve must **track the old values through ~game 12**
|
||
and only then extend, or you have silently nerfed the part of the game that was already fine.
|
||
The raw dials do run out around game 25 by design; bosses and ranged attacks (steps 4-5) are what
|
||
carry escalation past that, not more bodies.
|
||
- **`woundStage(zb, n)` in config.js is the ONE rule for how damage READS.** 0 = unhurt, 1..n = the
|
||
skins, spread evenly over `hpMax`. 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 in
|
||
`config.ZWOUND` (healthiest first, `…b` stride 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 same `woundStage`.
|
||
- **A chapter boss's MOVEMENT is data on its `BOSS_ART` row** (`move`, plus optional `hpMul`,
|
||
`speedMul`, `volley`, `gap`, `spread`, `cycle`, `wind`, `shot`), and `bossAI` in 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 off `zb.dist`
|
||
(ground covered on both axes), not `zb.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 in
|
||
`pretoss` for 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 chips `TUNE.dmgPellet` = 0.34 of a point); they bank on
|
||
`zb.chip` and only WHOLE points come off `zb.hp`, because the wound skins and `bleed()` anchors
|
||
switch on hp thresholds and a fractional hp would never match one. It returns true when the body
|
||
should die and the CALLER kills it — `splatter()` when the kill should get the multi-kill fanfare,
|
||
`killZombie()` for a quiet one — after which the caller must stop touching `zb`, because
|
||
`killZombie` splices the array. A boss with 20 hp therefore needs no new damage code at all.
|
||
- **Boss damage state is read as a FRACTION of `zb.hpMax`, never against literal hp numbers.**
|
||
`spawnRacketZombie` scales hp with `TUNE.racketHpPerGame` and stores `hpMax`. Two places switch on
|
||
it and they MUST stay in step: `drawZombie`'s skins (`<= 0.34` headless `z_cw2`, `<= 0.67` torn-arm
|
||
`z_cw1`) and `bleed()`'s wound anchors. The old hardcoded `hp === 2` left a scaled boss looking
|
||
pristine until the killing blow.
|
||
- **The racket boss has NO health bar, deliberately.** It was the only floating UI on a body anywhere,
|
||
it was on screen for nearly the whole run (a boss respawns every game you win), and players did not
|
||
know what it meant. The wound skins already encode the same information in the language every other
|
||
multi-hit enemy uses. A one-per-run modal (`G.seen.racket`, fired from `spawnRacketZombie`) explains
|
||
what he *is* instead. Do not re-add a bar; give a new boss more wound stages.
|
||
- **There are TWO damage channels and they must never be merged.** Contact with a body is still
|
||
instant permadeath (`killPlayer`, `G.mode='eaten'`); ranged attacks chip `G.hp` (`hurtPlayer`).
|
||
Oliver settled this explicitly — two threats that demand two different responses is the depth, and
|
||
making contact chip instead of kill would lose the dread the whole game runs on. `killPlayer()` is
|
||
the single death path for BOTH causes (extracted for exactly that reason: the run summary, the
|
||
best-run localStorage write and the death animation exist once). `TUNE.hurtGrace` is what stops a
|
||
three-shot volley deleting you in one frame.
|
||
- **The player's health is drawn ON HIM and nowhere else.** No bar, no meter, no HUD number — same
|
||
rule that removed the boss's bar. `drawPlayerWounds` paints procedural spatter at FIXED anchors
|
||
(fixed so it cannot crawl frame to frame), `bleedPlayer()` adds the live trickle, and `G.hurtFlash`
|
||
washes the screen edges inward on impact. Blood is never painted into the player sprite: that is a
|
||
settled feel decision, which is why this is an overlay and not a set of bloodied-kit PNGs.
|
||
- **`shots[]` in zombies.js is the HOSTILE ball array** — the ball machine's shots and the chapter
|
||
boss's volleys. Deliberately separate from `ball` (there is only ever one of those and it is yours)
|
||
and from `extras` (also yours, and they kill zombies). Three outcomes, in this order: a swing inside
|
||
the volley window RETURNS it — as an `extras` clone at `TUNE.parryPow`, so "a parry is a real
|
||
weapon" costs nothing to build; `meleeSwipe` merely bats it away; reaching you unanswered costs hp.
|
||
**The swish path is not optional politeness** — in `pretoss` the player is rooted and cannot swing
|
||
at all, so without it a machine winding up while you lined up a serve was an unanswerable hit.
|
||
- **`isBoss(zb)`** (`'r'` or `'B'`) marks bodies that take `wound()` damage from everything instead of
|
||
being deleted by it. The brute `'g'` is deliberately NOT one: melee/bomb/fire one-shotting a brute
|
||
is a settled "power exception". Every weapon path checks it — miss one and that weapon silently
|
||
no-ops or one-shots a 34 hp boss. This has already been the bug twice.
|
||
- **The chapter boss scores NOTHING when it dies.** Its reward is the chapter card plus the parked
|
||
game beat resuming. Letting it bank a kill meant `splatPoint`'s game-over branch and
|
||
`endBossFight` both decided "what happens next" in the same frame and clobbered each other.
|
||
- **`blocksClear`** decides which bodies gate the hand-clear after a won game: NOT the racket zombie
|
||
(it keeps its residence) and NOT the ball machine (furniture — a standing threat you deal with when
|
||
you choose). Making the machine gate progression turned "decide what to prioritise" into a chore
|
||
every four games.
|
||
- **The parry is bound to the TAP, in every phase, and that is load-bearing.** `keyPressed` checks
|
||
`shots` for anything within `TUNE.parryArm` FIRST and turns the tap into a swing if so. Before that,
|
||
the parry sat behind the `phase === 'rally'` branch at the bottom of `keyPressed` — and splatter is
|
||
in `pretoss` nearly all the time, so a tap aimed at an incoming shot tossed the serve ball instead
|
||
and the return was unreachable in practice. Oliver could not land one in a whole session.
|
||
`TUNE.parryReach`/`parryWindow` are deliberately much more generous than a normal volley (2.4 m /
|
||
0.34 s), because this is a phone, one thumb, against something aimed at you. The fail cases still
|
||
exist and are what keep it a timing skill: tap before the shot is inside `parryArm`, tap after it
|
||
has passed, or don't tap. **`parryWindow` must stay <= `swingWindup + swingWindow * VOLLEY.window`**
|
||
(0.348 s) or update()'s swing clock expires before the window closes.
|
||
`meleeSwipe` deliberately does NOT touch `shots`: it runs first in `keyPressed`, so it used to eat
|
||
the very shot the parry was about to return.
|
||
- **`zb.hitT` (`TUNE.bossHitCool`) stops a LINGERING ball multi-wounding a boss.** A flame ball has
|
||
full pierce — it is not consumed on contact, so it can cut a lane — but a boss is not removed when
|
||
hit, so the same ball sat inside its radius and re-wounded it *every frame*. One flame ball killed a
|
||
16 hp chapter boss. Flame now also spends itself (`lastHit = 'X'`) on a boss specifically. Any new
|
||
weapon that is not consumed on contact must set `hitT` too.
|
||
- **No blood is painted onto the player.** Oliver: *"the player blood looks odd, appears as a painted
|
||
layer on top"*. His health reads as `bleedPlayer()`'s live drip plus the screen-edge wash on impact,
|
||
and nothing else. This is the same settled rule that governs the zombies — blood is never painted
|
||
into a sprite — and a `drawPlayerWounds` overlay broke it.
|
||
- **Boss designs are DATA: `BOSS_ART` in config.js, one row per chapter, cycling.** Each row is
|
||
`{ name, walk: [2], wounds: [...], throws: [...] }`; `spawnChapterBoss` stamps the row onto the body
|
||
as `zb.art` and `drawZombie` reads only that. Damage picks a wound stage as a fraction of `hpMax`
|
||
spread over however many stages the design HAS, and `throws` is index-clamped, so a design with
|
||
fewer throw frames than wound stages just reuses its last one. Adding a boss is a sheet plus a row —
|
||
never a change to drawZombie. Missing/unloaded sprites degrade to design 1 rather than vanishing,
|
||
which is convenient and also means **a wiring bug looks exactly like working code** (see pipeline
|
||
note 7).
|
||
- **Sprites are 2x (`SPRITE_H` 224, `_slice.py` `IDLE_TARGET_H` 224) and EVERY scale-critical sprite
|
||
must share that scale.** `RES` is 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 through `drawChar`, `drawBoy`, `drawMachine` or the wound-stage `h *= img.height / SPRITE_H` rule
|
||
are all measured against `SPRITE_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 plain `drawStatic(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_smash` is 322), and that ratio is exactly what `SPRITE_H` exists 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.horizon` must ride with it.** `fitViewport`
|
||
picks `h = 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 off `horizon`, and the rack/meter hang off `H - ...`, so extra height
|
||
lands entirely in the apron between them: leaving `horizon` fixed just grew a dead green band.
|
||
It is computed as `45 + (h - 480) * 0.72` for that reason. `RES` is 4 (was 3) so an iPhone's ~1125
|
||
device px across is nearly 1:1 with our 1080 — **`RES` is 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 already `display:none` on `pointer: 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 at `meleeCoolMin`, counting bodies within
|
||
`meleeCrowdR`. 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 with `hordeGrow` while the panic button was a flat 1 s; he chose scaling the
|
||
melee over shrinking the horde. **Measure the crowd BEFORE calling `meleeSwipe`** — it kills
|
||
everything in reach, so counting after counts the survivors and the cooldown barely moves.
|
||
- **`render()` clears the whole frame to `pal.out` first.** 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 — sample `getImageData` alpha, 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 approaches `TUNE.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`, and `html` needs a real height.** Two
|
||
separate CSS traps, both hit: the ROOT element's background is what paints the viewport, so setting
|
||
it on `body` while `html` also has a background painted nothing; and a GRADIENT is laid out in the
|
||
root's own box, which collapses to zero when the canvas is `position: fixed`, so `html { height:
|
||
100dvh }` is load-bearing rather than cosmetic.
|
||
- **The page background behind the canvas tracks the live court apron** (`pageBackground` in
|
||
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: `#10141c` down to
|
||
the top inset, then `pal.out` for the rest. **`pal.out` must 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 than `env()` 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.top` stays 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, via
|
||
`SAFE.bottom`, which `measureSafe` reads from the hidden `#safe` probe (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 `fitViewport` derives the logical
|
||
grid from the box it was actually given** — `cv.clientWidth/clientHeight`, never `innerWidth/innerHeight`.
|
||
Sizing it the other way round (compute a scale, then set explicit px) laid it out against whatever
|
||
`innerHeight` reported at load, before iOS Safari's toolbars had settled, and it stayed a strip short
|
||
of the bottom forever after. `100dvh` is 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=cover` is set, and the stands running to the screen edges look right —
|
||
but an iPhone's clock was landing directly on the scoreboard. A hidden `#safe` probe div carries
|
||
`env(safe-area-inset-*)` (env() is only readable from CSS), `measureSafe()` converts it to LOGICAL
|
||
canvas px using the same scale `fitViewport` just applied, and `SAFE.top`/`SAFE.bottom` shift the
|
||
scoreboard down and lift the rack + `METER_Y` up. 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 shares `rackGeom`, so taps
|
||
follow automatically.
|
||
- **`UI` in config.js is the accent palette** (hot/warm/pick/cream/dim/bad/ball). Every HUD colour
|
||
goes through it. Gore reds are deliberately NOT in it — the splatter stays loud.
|
||
- **`F(size, bold)` in render.js builds every canvas font string.** Bare `monospace` resolves to
|
||
Courier, a slab SERIF — that was the "generic serif" bug, not taste. Never hand-write `ctx.font`.
|
||
Widest string checked fits 270px portrait with ~10px spare.
|
||
- **Weapons are only ever WON.** `deliverTin()` has exactly one caller: `resolveInterlude('P')`.
|
||
The schedule (`TUNE.duelFirst`/`duelEvery`, `G.duelDue`) calls the **duel**, fired from
|
||
`proceedAfterClear()` (beats.js) — the one beat where the court is guaranteed empty. The game beat it
|
||
interrupts is parked in `G.afterDuel` and resumed when the duel resolves.
|
||
- **Anything that blocks serving.** If a "can't serve" report ever comes in, check these first —
|
||
each is an early return in `keyPressed`: `G.mgT`, `G.horde`, `G.changeover`, `G.interlude.introT`,
|
||
`G.modal`. `G.mgT` in particular must keep counting down in EVERY phase (it used to tick only
|
||
during `rally`, so a duel calling `setupPoint()` stranded it above zero forever = permanent lock).
|
||
- **Animation frames step off DISTANCE COVERED, never elapsed time** — `boy.dist` for ball boys,
|
||
`zb.z / TUNE.bruteStride` for brutes. Time-driven cycles moonwalk the moment speed and frame rate
|
||
disagree. Sprite heights follow the PNG (`img.height / SPRITE_H`) because `_slice.py`
|
||
common-scales a sheet; a wound stage's height must ALWAYS come from its A frame or it pops.
|
||
- **Fire chains.** `zb.burnT`/`burnD`; `ignite()` is read-only over `zombies[]` so it is safe inside
|
||
a kill loop; `burnTick()` returns true when the fire killed the body and the caller MUST
|
||
`continue`. The flame ball also ignites what it FLIES PAST (`TUNE.burnBallR`) — without that its
|
||
full pierce killed its own lane outright and nothing was left to catch. Flames are drawn
|
||
procedurally (`drawFlames`), no sprite sheet.
|
||
- **Any loop over `zombies[]` whose body can kill** can cascade into a sweep and gut the array
|
||
mid-iteration — keep the `if (!zb) continue` guards and iterate backwards.
|
||
- **The ball boy is ONE slot.** `sendBoy` never replaces a boy already on court (a kill streak used
|
||
to teleport him back to the wing several times a second); new work is appended via `jobs`.
|
||
`mopRound` walks him to a stain, holds him there for `TUNE.boyMopDwell` while the push cycle
|
||
animates in place, then moves him on. Three sheets: `x_boymop` (stopped, working),
|
||
`x_boycarry` (walking with the mop shouldered), `x_boyrun` (tennis-mode fetch).
|
||
- **Crowd applause is a real two-frame swap** (`x_crowd` seated / `x_crowd2` arms up), per column on
|
||
its own phase, with **no vertical offset**. A fractional `dy` on a scaled image re-snaps its pixel
|
||
rows every frame under nearest-neighbour — that boiling beside the HUD was the "scoreboard
|
||
flicker" (the board art is 99.9% opaque; nothing was ever showing through it).
|
||
**If you touch one crowd frame you must apply the identical curve to the other**, or the swap
|
||
starts flashing brightness. `assets/_sheet_crowd1.png` is the pre-brightening original.
|
||
|
||
## Settled feel decisions — do not regress silently
|
||
- **Far bodies draw off a flattened scale curve; the camera holds still.** `TUNE.farK` 0.25
|
||
(render.js `farB`): 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 below `CONTACT.minY` is past you.
|
||
- **Aim magnet**: `flickTarget` picks the best-ALIGNED body, with `TUNE.aimTie` of slack in which a
|
||
nearer one still wins, so a straight-ahead lane still eats the nearest body first.
|
||
`aimTie: 1` restores the old "nearest along the lane always wins" for A/B.
|
||
- `TUNE.fps` 60 — 30 was vetoed on feel.
|
||
- **The player only walks during `rally`.** Never between points. Letting him walk during the 'over'
|
||
beats and `pretoss` was shipped once and vetoed immediately: *"I basically can never walk around
|
||
freely"* — so suddenly being able to, only between points, reads as the game breaking rather than
|
||
as a fix. Frozen-with-danger-approaching is fixed by holding the DEAD instead (`held` in
|
||
`updateZombies`), never by handing back movement.
|
||
- **The player must always be ON SCREEN.** `P_BOUNDS.x` is 6.5 m but portrait only shows about
|
||
±4 m either side of the centre line at the baseline, so the walk bound alone let him leave the
|
||
frame entirely. `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, carrying `TUNE.dodgeIFrame` seconds that beat BOTH damage
|
||
channels — the chip damage in `hurtPlayer` and the instant-death contact test. It exists because a
|
||
boss fight is spent in `pretoss`, 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 `DEAD` crossing, 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.aimable` is 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)
|
||
for `SET`, `CHAPTER`, `EPIC!`, the praise words and every free-floating flash — things shouted at
|
||
you. EVERY floating string goes through `shadowText`, 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.** `pullQuality` SATURATES: `pullFrac` clamps at
|
||
`pullFull`, so every pull past 130 px is exactly quality 1.0 and the old `quality > 0.999` test
|
||
praised nearly every serve — *"it displays perfect a lot even when nothing special happened"*.
|
||
`hitServe` takes `perfect` as its own argument now: the toss ritual still earns it on the 45 ms
|
||
`perfectMs` window, the slingshot earns it by STOPPING in `pullFull..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 through `PRAISE` (cycled, not
|
||
random: the same word twice running reads as a stuck HUD element, not as praise) and sits behind
|
||
`TUNE.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.
|
||
|
||
## 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.s` so 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.aimCloud` gates 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 from `TUNE.flickMin` for 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
|
||
turns `UI.bad` when 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
|
||
(`g`ore / `z`ombie tennis / `c`lassic; 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
|
||
|
||
- **`autoAssist` is 0 on `pointer: fine`.** The desktop pane has no auto-run. The match floors it
|
||
with `matchAssist`, and `assistK()` is shared by the mover AND `intercept` — computing
|
||
reachability with the raw knob made the planner answer "run home" forever.
|
||
- **The contact chain is three separate things**: LEGS (`matchAssist`) → DESTINATION (`assistK` in
|
||
`intercept`, plus the `anyHit` short-ball candidate that chases dying rollers) → REACH
|
||
(`matchReach`). Each was a separate defect found in a separate recorded session.
|
||
- **`ball.lastHit` must be stamped by `tossBall`.** 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.serveNum` is only reset by tennis's `pointTo`**, 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 for `G.match` / `rallyFeel()` instead.
|
||
- **`stats.banked` is 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. `matchReach` and 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 `drillOneShot` on as a default — it skips every `ZWOUND` stage, 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.paired` is what takes
|
||
the court**, called at the top of `netStart` — one line before everything that writes to it, so
|
||
the kit/tour selection `start()` performs is already done when the `look` message reads it.
|
||
Two more hooks join `intoSplatter`: `room` (the code landed) and `lost` (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.share` is the phone's own sheet —
|
||
the friend is in WhatsApp, not in a room reading letters aloud — with `clipboard.writeText` as
|
||
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-ABCD` door 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 new `stats.saveName` (which `submit` now shares), so the world board's name and the netplay
|
||
name cannot become two things. It crosses on the EXISTING `look` message as `n` — 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 same
|
||
`tidyName` the board and `server.py`'s `clean()` agree on.
|
||
- **Failure is a typo now, not a card.** A short code never opens a socket; `gone` puts
|
||
NO SUCH ROOM — CHECK THE CODE over the picker with the field still there. CANCEL and the header
|
||
chevron both `endNetVs`, 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 .btn` loses to `#vs .btn`'s margin, and a margin on a stretched flex
|
||
item is exactly the 8px it was coming up short by); `.tin` is 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 `barrelFloor` of 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.
|
||
- **`zSpeedMax` 2.9 → 3.6** — shamblers keep gaining speed to ~game 28 instead of 22.
|
||
- **`nastyMax` 0.75 → 1.0** — by game 34 there are no ordinary shamblers left in the mix at all.
|
||
- **Ball machines scale**: `machCap()` is 2 + one per `machMaxPer` games, capped at `machMaxCap`.
|
||
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 hand `BOX`; 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 a `BOX` row is the fix.
|
||
- The club pro finally has a head icon too (`z_rhead.png`, cut from `z_c1` — the turned opponent
|
||
IS him), so `SPR` preloads 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.classic` with 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. `#net`
|
||
hosts and `#net-ABCD` joins 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 and `mirror()` (x → -x, z → COURT.L - z) rotates them half a turn.
|
||
The 10 Hz `pos` stream 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. An `applying` latch 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.net` joined every
|
||
other start*'s refusal list. Serving is barred while unpaired or dead (`keyPressed`), O's auto
|
||
serve and rally AI are gated off, `restartRun` closes 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.js `farB(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
|
||
via `fOff` in 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:
|
||
|
||
1. **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.apexSnap` 0.05 / `apexFall` 0.55,
|
||
and the gold band on the apex guide reads the same knob, so the picture tightened with it.
|
||
2. **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.
|
||
3. **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`, with `serveHardSpin` — 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 for `TUNE.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 the `mgT` lock 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-arms `pendingHorde` — 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.bad` over
|
||
`ENTERED THE COURT`. It is a modal (a boss stage is guaranteed calm — `startBossFight` clears the
|
||
court two lines earlier, so this is on the sanctioned list) with the new `m.hold` self-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
|
||
touches `startHorde`; 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.**
|
||
|
||
- **`voiceSeq` in 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 in
|
||
`drawBib`). 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, `drawBib` is 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) and `zombieTarget` (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 logs `INVARIANT`, the
|
||
same contract `watchdog` uses. `startDrill` also 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: `aimTie` is **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:
|
||
`splatServeTarget` returns null and `hitServe` falls 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 Daniel` recordings) **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.
|
||
- **`PLACEMENTS` in 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:
|
||
|
||
1. **A SUSPENDED AudioContext HAS A FROZEN CLOCK.** On a phone the context starts suspended and
|
||
`currentTime` does not advance until it resumes. Scheduling four clips at `currentTime + 0, +0.9,
|
||
+1.7, +2.4` against 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.
|
||
2. **A SECOND CALL LAYERED INSTEAD OF REPLACING.** `voice()` has always enforced one man, one
|
||
microphone; `voiceSeq` SET that clock (`voiceUntil`) and never checked it. Two calls close
|
||
together = two overlapping lists. It stops the outstanding one now.
|
||
3. **CANCELLING A LIST HAS TO GIVE BACK THE TIME IT BOOKED.** The first cut of fix 2 stopped the old
|
||
sources but left `voiceUntil` standing, 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. `seqUntilBefore` rewinds the booking.
|
||
4. **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 `drillBeat` seconds: 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 `fetch`es 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.drillTones` 0 makes it silent. The `drill_*.m4a` recordings stay in assets: cheap to keep,
|
||
and a slower variant of the mode could want them back.
|
||
- **`PLACEMENTS` is 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. `updateZombies` homes
|
||
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.drillRampT` 210 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 real `zb.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 compare `zombies[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:
|
||
1. **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.
|
||
2. **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 `killZombie` did
|
||
`drillKill(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.drillRingR` sizes 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.chain` branch. 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, `drillTones` from TUNE, the correct-kill tick from
|
||
`drillKill`. 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"*.
|
||
- **`sendBoy` refuses during a drill**, at the function rather than at the call sites (same shape as
|
||
its existing `G.interlude` guard, 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**; the `gore` 40 / `piles` 30 caps are
|
||
what keep it bounded, which they already did.
|
||
- **The eaters are rarer**: `drillEaterEvery` 7 -> 12 s, `drillEaterMin` 2.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:
|
||
1. **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;
|
||
2. **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 in `ZWOUND` (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 =
|
||
false` in drillKill's completion branch, and in `drillCaught` for 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.drillEaterMax` 3 -> 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 `drillOneShot` must stay off: 1 hp bodies delete the planning layer.
|
||
- **THE SLACK** (`drillSlack` 0.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 (`drillMultEvery` 4, `drillMultCap` 5), 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,
|
||
and `drillMark: 0` still 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 (`explode` at the body).
|
||
The scoreboard and the corpse agree all the way down. `matchOppHp` 4 + 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 — `matchHeal` 2 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 `matchRingSweet` 2.7 m — the
|
||
centre of the tapped-points benchmark's window — gold inside `matchRingBand`), 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 clear `G.modal` AND `G.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"*.
|
||
- **`matchPlayerHp` 8** — 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) and `matchFlubKeep` (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's `pointTo` is 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. `matchReady` drops 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**: `matchSpeedBase` 1.4 (the duel's 2.8 m/s lumber was tuned
|
||
for ONE sudden-death point, not a game) and `matchFlub` 0.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`/`matchFlub` are 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.
|
||
- `matchSpeedBase` 1.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.placeMark` and 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.
|
||
- **`matchWideBase` 2.1 / `matchWidePer` 0.4 — the opponent's WIDTH is the ladder's third dial.**
|
||
Its returns scatter off `aimError 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.hp`
|
||
directly: 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** (`matchOppPace` 0.78, `matchOppPacePer` 0.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.
|
||
- **`matchAssist` 1.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 with `autoAssist` 0 — 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. `intercept` now keeps an `anyHit` candidate — 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.
|
||
- **`intercept` computed reachability with `autoAssist`** — 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.z` after 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:**
|
||
1. ~~**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.
|
||
2. ~~**The tap aims itself.**~~ CLOSED by the second pass above — the snap is gone, you aim.
|
||
3. ~~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.
|
||
4. 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.
|
||
5. **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; every `start*` enforces it itself). Pure move, zero behavior
|
||
change, verified by the suite passing untouched before and after.
|
||
- **This file renamed** `HANDOFF.md` → `PROJECT.md` and 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), all `HANDOFF` references in code comments
|
||
updated. `T.step`/`T.press`, `machEvery` 4, `bossEvery` 10 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.md` added** (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 for `endHorde`.
|
||
- `startDuel()` and `startHorde()` 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 — `resolveInterlude` is the duel's only exit. (A machine
|
||
or leftover killed mid-duel used to bank score and could re-enter `proceedAfterClear` mid-duel.)
|
||
- **Invariant net** in `watchdog()`: if horde+interlude ever coexist anyway, or the phase leaves
|
||
`rally` during a horde, it repairs on the spot and logs `INVARIANT` into `G.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. `RES` is `export let` now, 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. Set
|
||
`lowPower: 0` for 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.
|
||
- **`beamLen` 26 -> 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.
|
||
- **`beamW0` 0.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 takes
|
||
`dmgBeam` 8: "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.
|
||
|
||
- **`beamLen` is 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.beamBand` is 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 * 2` vs the cone's taper) in BOTH `fireBeam`
|
||
and `drawBeamCuts`, 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_CORE` are 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 (`beamOriginY` 1.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 composited `lighter`, 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.** `drawBeamCuts` runs after the beam: for each body in the cone, the
|
||
part above `proj(zb.x, beamY, zb.z).y` is redrawn clipped, so it sits ON TOP of the plane while the
|
||
part below stays tinted underneath. `drawZombie` mutates 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 `fillRect` spanned 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
|
||
by `ctx.filter`. Thickness scales with `h * 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 `sepia` to 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_FILTER` is feature-detected** and the line falls back to a narrow bar without it (older
|
||
WebKit has no `ctx.filter`).
|
||
- **The cut pass is BOUNDED** (`CUT_MAX` 8 nearest bodies, `CUT_MIN_PX` 14): 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: `testZMax` 8 bodies, `testZSpeed` 1.15 m/s, `testSpawnEvery` 1.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, and `G.runGames` keeps climbing underneath.
|
||
- `testLevel` 20 is the roster level `pickVariant` pretends 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 `testBarrelEvery` seconds, 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.html` loads
|
||
`src/main.js?v=N`, and `fetch('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.15` first** — the Browser pane reports `pointer: fine`, so
|
||
the mobile assist is off and every ball reads as "never reachable".
|
||
- Force splatter with `T.forceZombies()` then ~600 steps (in splatter it forces a HORDE instead).
|
||
- `T.zombies`, `T.spawnZombie(x, z, 'a')`, `T.TUNE.zMax = 0` to stop spawns during tests.
|
||
- Also exposed: `T.explode`, `T.splatPoint`, `T.startDuel`, `T.resolveInterlude`, `T.deliverTin`,
|
||
`T.flickTarget`, `T.showModal`/`T.dismissModal`, `T.render`.
|
||
|
||
**Benchmarks that must keep holding** (re-run after any physics/aim change):
|
||
- **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 stash` the change, reload the modules, run the identical rig, compare. Equal
|
||
numbers on the same rig is the real claim; a bare percentage against this line is not.
|
||
- 30 human-tapped points (tap when the incoming ball is 2.2–3.2 m away) = **50 hits / 0 whiffs**.
|
||
This one DOES reproduce exactly — drive it off `G.missLog` (HIT vs MISS counts).
|
||
- A 26-game state-machine soak with bombs/bursts/fire must finish with no exceptions, no stall,
|
||
`mgT` back to 0, and gore/piles arrays bounded (gore caps at 40, piles at 30).
|
||
**Two ways a soak lies to you**, both cost me a run: force-spawning zombies during `G.clearing`
|
||
makes the hand-clear un-completable and reads as a 30-minute stall; and reviving the player after
|
||
each `eaten` inflates gore past its cap, because the death push (`zombies.js`, the `eatenR` hit) is
|
||
the one uncapped `gore.push` in the file — fine at one death per run, not at 260. Respect
|
||
`clearing`, and either end the soak on death or set `TUNE.eatenR = 0` to make him untouchable.
|
||
- **Never trust a single sample.** aiFlub is 9%; a 20/24 run is normal noise. Use 150.
|
||
|
||
## Asset pipeline
|
||
|
||
**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`. |
|
||
|
||
**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.
|
||
|
||
1. `generate-image` skill (needs `OPENROUTER_API_KEY` in the gitignored repo `.env`; run with the
|
||
scratchpad venv python — system python lacks `requests`). Passing the existing sheet as
|
||
`--input` (edit mode) nails the style and is how every matching sheet here was made.
|
||
2. Ask for a **magenta background** and, for sheets, "at least 150px of empty magenta between
|
||
frames" plus "the prop must stay inside its own frame".
|
||
3. `python3 assets/_slice.py sheet.png out1.png ...` — chroma-key on the corner colour, magenta
|
||
de-fringe, column-gap segmentation, common-scale per sheet, `IDLE_TARGET_H=112`. `--largest`
|
||
keeps only the main blob.
|
||
4. Standalone art (logo, popup panels) uses `assets/_key.py` instead: chroma-key + crop to the
|
||
content bbox + optional downscale, no frame splitting, no forced height.
|
||
5. Register new names in `render.js`'s `SPR` preload array.
|
||
6. **New sprite not showing up? Bump `SPRV` in render.js.** Sprite loads go through
|
||
`assets/<n>.png?v=SPRV` for the same reason index.html versions main.js: python http.server sends
|
||
no cache headers, so the browser caches sprite responses heuristically — **including 404s**. Add a
|
||
name to the `SPR` list before its PNG exists, load the page once, and that browser treats the
|
||
sprite as permanently missing while `drawZombie` silently falls back to another design. Bump SPRV
|
||
alongside `main.js?v=N`.
|
||
7. **When a scripted `str.replace` edit is meant to rewire logic, ASSERT it matched.** A silent
|
||
no-op replace left `drawZombie` on its old hardcoded boss names while everything else had moved to
|
||
the BOSS_ART table, and the built-in fallback then hid the failure perfectly — all four boss
|
||
designs rendered as design 1 and nothing errored. Cost a full debugging round. `drawImage`
|
||
instrumentation is what finally settled it:
|
||
```js
|
||
const o = CanvasRenderingContext2D.prototype.drawImage;
|
||
CanvasRenderingContext2D.prototype.drawImage = function (img, ...a) { log(img.src); return o.call(this, img, ...a); };
|
||
```
|
||
8. **NEVER let two sprite names differ only by CASE.** macOS is case-insensitive: writing
|
||
`assets/z_B1.png` for the chapter boss silently OVERWROTE the 'b' shambler's `assets/z_b1.png`
|
||
(and `z_B2` ate `z_b2`), so a 3-metre boss started showing up in the shambler crowd and `git`
|
||
reported the shambler frames as "modified" rather than reporting a new file. `ls` shows only the
|
||
ORIGINAL lowercase name, which is why it is easy to miss. Boss frames are `z_boss*` now. Use
|
||
all-lowercase, whole-word names.
|
||
|
||
9. **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.py` carries the working style block.
|
||
10. **`assets/_regen.py` + `_regen_batches.json` redraw 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. `--list` shows the batches, `--all` runs them.
|
||
11. **`assets/_fine.py` is the slicer for detailed art.** `_slice.py` resamples NEAREST, which is
|
||
right for chunky art and shreds fine art at a non-integer ratio. `_fine.py` snaps to the
|
||
generator's own art-pixel grid, common-scales in ART pixels off a chosen `--anchor` frame (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.
|
||
12. **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.py` has 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, because `newart` looks 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_STYLE` pins 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()` measures `density()`
|
||
of the file it is about to replace, and `colour_match` opens it. Seed each new name by copying a
|
||
body of the right size and facing, and use `check: same`/`loose` so `colour_match` is 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 scratchpad `check_seeds.py` does exactly this).
|
||
13. **`assets/_derive.py` is where a frame that must MATCH another frame is made.** Three of them
|
||
live there now: `p_bounce2` (a per-column shear of `p_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 `--runner` and
|
||
must run exactly once per generated set, because it mirrors and it pads.
|
||
14. **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`'s `ref` field does this, but
|
||
only when `ref_only` is unused — with `ref_only` set, list the throwaway as an extra sprite
|
||
(`p_pull0`) and delete the PNG afterwards.
|
||
|
||
15. **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.py` de-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. Measure `alpha > 128` as 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 halo `drawBarrel` paints 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.py` reports "found 1 frames". Re-prompt
|
||
for wider gaps first; if that fails, cut at the true column gaps plus the thinnest column inside
|
||
the merged pair, then common-scale.
|
||
- **Colour-match any regenerated frame** of an existing pair before shipping: fit a per-channel
|
||
linear map onto the original's mean and sd, then verify with a mean-difference number.
|
||
|
||
## 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`, shambler
|
||
`1.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.py` gained
|
||
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.py` now skips a batch whose `ref_only` is in a failed
|
||
batch's outputs.
|
||
- **A placeholder is a stand-in for art that does not exist yet, and nothing else.** Re-running
|
||
`--variant` to 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.py` keyed 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.fromarray` images are READ-ONLY and PIL's `floodfill` swallows 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
|
||
|
||
- **`changed` does 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.ZFAMS` is 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: `--verify` found
|
||
27 of them dead. The brute is now live (`ZFAMS` has `'br'`, the KEEPER wears it) off ONE sheet.
|
||
|
||
## 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.Haptics` reads like the obvious
|
||
> zero-dependency answer and it is **silently wrong**. The injected `native-bridge.js` defines
|
||
> `Capacitor`, `isNativePlatform` and `nativePromise`, but leaves `Plugins` EMPTY — it is
|
||
> `@capacitor/core`'s `registerPlugin`, i.e. exactly the JS we are not shipping, that fills that
|
||
> object in. Going through `Plugins` therefore falls straight through to the web branch *inside the
|
||
> app*, where `navigator.vibrate` does 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-pty` shows `⚡️ To Native -> Haptics impact` for the
|
||
> `nativePromise` form. **That is the check to repeat for the next plugin**; `simctl spawn log
|
||
> stream` will NOT show it, because Capacitor `print()`s those lines to stdout.
|
||
|
||
- **`npm run www`** rsyncs `index.html`, the manifests, `src/` and `assets/` into `www/` with the
|
||
same `assets/_*` exclusion `.dockerignore` uses — 32 MB, not the 463 MB the sprite pipeline sits
|
||
on. `npm run sync` = tests + that copy + `npx cap sync ios`; `npm run ios` opens Xcode after it.
|
||
`www/` and `node_modules/` are gitignored; `ios/` IS committed (Capacitor's own `ios/.gitignore`
|
||
keeps the copied payload, Pods and Xcode's per-user junk out of it).
|
||
- **Portrait is enforced where it can be**: `Info.plist` lists 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: portrait` in both manifests and one guarded
|
||
`screen.orientation.lock()` in `main.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 in `haptics.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.js` pins 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 `.chev` and 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. The `touchend` listener is
|
||
deliberately **not passive**: a swipe that starts and ends inside one of the big poster cards also
|
||
produces that card's click, and `preventDefault` is 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:
|
||
1. **`fetch` responses from the app bundle are OPAQUE.** Capacitor serves `capacitor://localhost`
|
||
through a custom `WKURLSchemeHandler`, and `fetch` reports those as `status: 0, ok: false` —
|
||
with the full body present and decoding perfectly. `audio.js` guarded all three of its loads
|
||
with a plain `if (!r.ok) throw`, so inside the app EVERY recorded sound failed at the door: the
|
||
umpire fell through to the synthesized `say()`, 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 shared `bytes()` 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.ok` guard on a bundle-relative fetch is this
|
||
same bug.** (`stats.js` is fine: its board calls go to the live host over real HTTPS.)
|
||
2. **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. `AppDelegate` now sets
|
||
`AVAudioSession .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 is `running`
|
||
**with no gesture at all** (autoplay really is allowed in WKWebView, as LATER predicted), and the
|
||
`say()` 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`), so `fetch('api/scores')` and the netplay socket would have resolved
|
||
against the app's own files. `config.NATIVE`/`config.API` and one branch in `net.js` point 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_frame` and `last_frame` set 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.** `--cut` scores every frame against every lag with `_famref`'s
|
||
`legs`/`stride_diff` and 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 `drawChar` probes
|
||
run8/run6/run4/run2, so a good 6 beats a fought-for 8. State the count you can SEE with `--n`
|
||
when 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
|
||
|
||
```bash
|
||
# 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_size` was validated on
|
||
single-row `_regen` sheets 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 `Read` of each would have located
|
||
the bug immediately; instead the first two theories (legacy `_regen` prompts, 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:
|
||
|
||
```bash
|
||
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.
|
||
- **`n` is 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 `#FF00FF` match 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 against `z_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.py` anchors 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.py` learned to keep enclosed background, so their string beds are transparent — `o_smash`
|
||
has 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_sheet` fills them with a woven pale bed now.
|
||
- **`--verify` asks 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_brw1fall` was 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 `serve` now: pull, aim, release. **The RAY is the weapon**, so there is no ball
|
||
anywhere in it — the preview is the WEDGE itself (`drawBeamWedge` at low alpha, not a ballistic
|
||
band with a target ring), the tap path has its own `tryLaser` so it never tosses a ball it is not
|
||
going to hit, and `fireBeam` kills everything inside the cone (`beamW0` 0.35 m at the racket to
|
||
`beamW1` 4.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.pullMin` is 18 **PIXELS**, and `aim.fy` is NEGATIVE for the down-pull.
|
||
A synthetic `{fx, fy, mag: 0.8}` is silently below the threshold and `previewServe` returns null,
|
||
which looks exactly like the preview being broken.
|
||
- **beacon** and **rocket** are `burst` and **LOBBED** — `arc` on 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):
|
||
- `drawImage` instrumentation per design: all six bosses and all three new bodies draw their OWN
|
||
frames, both `…w`/`…wb` stride twins included. Nothing silently falls back to design 1.
|
||
- Differential check on the refactor: the brute still draws `z_gw/gw2/gw3/gw4` at hp 4/3/2/1 — the
|
||
new shared `woundStage` is 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, `mgT` back to 0, gore capped 40, piles capped 30, and
|
||
`e`/`h`/`k`/`B` all 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 `tennis` palette and a drained `splatter` one and `palette()` 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** (`selectCourt` in 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_crowd2` are 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 from `x_crowd2`, for exactly this reason.
|
||
- **Ad-strip aspect is load-bearing.** `render()` draws the sheet at a fixed `AD_H` of 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 with `x_ads_h.png` as 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 `#intro` overlay, rows
|
||
built from `COURTS` in main.js. Same Silkscreen card, no new `G.mode`, no canvas UI code. Last
|
||
pick is remembered in `localStorage` and 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:
|
||
|
||
- `loopBed` now calls `audio()` 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 second `menuMusic()` from the first tap, and that used to return at the
|
||
`beds.has` guard without ever reaching the `AC.resume()` inside `audio()`.
|
||
- `fadeBed` records a duck for a bed that is STILL LOADING (`ducked`), and `loopBed` starts 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()` restores `G`, `P`, `O`, `ball` and `score` from a `structuredClone` taken 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 plus `spawnT`/`moanT`/`mgTick`) and `input.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 the `display: grid` on `#intro` would 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`.** `G` is deep-cloned into `PRISTINE` at load
|
||
and restored wholesale by `restartRun()`; a counter living there would be reset by machinery that
|
||
knows nothing about stats.
|
||
- **`killZombie` takes an index, not a cause.** Eleven call sites reach it, so instead of threading
|
||
a weapon id through all of them (and through `wound()`, and through `splatter()`), every damage
|
||
loop in zombies.js stamps a module-level `CAUSE` before 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-past `ignite()` did exactly that and filed 'fire' kills under 'ball'.
|
||
Burning bodies carry `zb.burnBy` for the same reason: they die long after the thing that lit them.
|
||
- **`endRun` needs an explicit `banked` flag, not an "is the bag empty" inference.** The games count
|
||
lives on `G` and survives the flush, so a second call sails past a nothing-happened guard and
|
||
writes a duplicate with zero kills. `pagehide` really 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, and `read()` 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`/`pagehide` drop the in-flight
|
||
gesture. `stick` and `G.aim` are only cleared by the touchend for the matching identifier, and iOS
|
||
does not guarantee that event when you switch apps mid-drag — a held `G.aim` then 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 `splatterId` once 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 ._-]` and `esc()` escapes them again on the way into `innerHTML`; keep both.
|
||
- The client's `tidyName()` mirrors `clean()` exactly. When it did not, the server stored `OLIVER`,
|
||
the client remembered `oliver`, 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 EDITING `court_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.py` itself needs `requests`,
|
||
which `/opt/homebrew/bin/python3.11` has.
|
||
- **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 to `pullFrac` was 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_bounce` and
|
||
`p_toss` had been drawn holding the racket in his LEFT hand while `p_smash` and `p_swing` used 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, so `BOUNCE_IDLE` moved to the other side and was re-measured off the sprite
|
||
(`dx` 0.36, `apex` 0.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:**
|
||
1. **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.py` cannot cut (it
|
||
segments columns). `ref_sheet` now widens to a landscape strip with one slot per frame asked for.
|
||
2. **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 of `z_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_aw` 75px -> `z_awb` 65px 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_g1` in 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
|
||
- **Nothing outside the four slot accessors may know how many slots there are.** `endBurst` had
|
||
`[0, 1, 2, 3, 4].find(i => slotId(i) === id)`, which was correct for exactly as long as the rack
|
||
held five. `RACK_MAX` 4 → 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. Use `slotOf(id)`.
|
||
The serve weapons were untouched because they all spend through `spendSlot(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 (`spendSlot` splices), 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.flash` is part of the world.** Its tick used to sit above the
|
||
`if (G.modal) return` in `update()`, 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 moment `weaponModal` became 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.
|
||
- **`deliverTin` walks the ladder, it does not cycle it.** `TINS[G.deliveries++ % 4]` handed you
|
||
whatever rung came next even when that type was already at `TUNE.wCap`, where `grantWeapon`'s
|
||
`Math.min` clamp 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 says `RACK FULL` when 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 — patch `AudioContext.prototype.createBufferSource` / `createOscillator` /
|
||
`createBiquadFilter` and diff around the event. To verify a PARAMETER reached the synthesis, patch
|
||
`AudioParam.prototype.setValueAtTime` and look for the frequencies you expect (that is how the
|
||
size scaling on `thud`/`splat` was 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 because `G.mode` had
|
||
become `'eaten'` several steps earlier and every gameplay entry point bails on that. When a probe
|
||
says a feature is broken, check `T.G.mode` before 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_start` only helps if the port actually changes,
|
||
and holding 8641 with a throwaway socket **did not work reliably**. What DOES work:
|
||
```js
|
||
await Promise.all(['main.js','state.js','gameplay.js','render.js','config.js','zombies.js',
|
||
'input.js','audio.js','panel.js','dom.js'].map(f => fetch(f, {cache:'reload'})));
|
||
```
|
||
then reload the page. **Always sanity-check a just-added field before trusting any result** —
|
||
and note the trap: if `state.js` is stale but `gameplay.js` is fresh, a new `G` field
|
||
materialises as **NaN** (undefined + dt), which reads exactly like a logic bug.
|
||
- `drawChar` mirrors sprites by `ch.facing` — any stance that must not mirror needs facing reset.
|
||
- Synthetic touch tests: input.js only sets `stick.moved` in touchmove, so touchstart→touchend
|
||
reads as a TAP. Always dispatch a touchmove between them.
|
||
- Contact/kill tests must step 1/60 — pellets at 26 m/s tunnel past 0.55 m radii at coarser steps
|
||
and kills silently don't happen, which looks exactly like a bug.
|
||
- **When testing fire or any serve, fire a real ARC.** A flat ball from z=16 hits the net
|
||
(netH 0.914) and bounces back at you; a "the weapon is broken" reading was my test hitting the tape.
|
||
- The scratchpad venv gets wiped between sessions — recreate it with `requests` + `pillow`.
|
||
- Commit style: imperative summary + `Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`.
|
||
Push = deploy.
|
||
|
||
---
|
||
|
||
## 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:
|
||
|
||
```js
|
||
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.
|
||
|
||
1. **`RES` 4 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, `RES` back to 3 is the first move** — not reducing zombie counts.
|
||
2. **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, `meleeCoolMin` is the knob.
|
||
3. **`p_idle1`/`p_idle2` and most `o_*` 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/3` and `_sheet_opp2/3` at `IDLE_TARGET_H` 224 rather than going
|
||
through the signature matcher, which assigned those frames elsewhere.
|
||
4. **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_ART` indices 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 of `boss_coach_wounds` / `boss_coach_throw`.
|
||
5. ~~**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 in `BOSS_ART`.
|
||
6. **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.plist` lists 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.
|
||
7. **`drawCard` (the chapter/set cards) still cuts in** with no entrance, while modals swoosh
|
||
(`TUNE.modalIn`). Offered and not yet requested.
|
||
8. **A chapter can be skipped.** `G.bossDue` takes 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.
|
||
9. **The runner's four frames were fixed BY HAND and must be regenerated.** `z_f1..4` are 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.
|
||
10. **Step 6 stays PARKED** by Oliver's decision: the crowd turning into zombies (spawning from the
|
||
stands — attacks from behind, high spectacle, `x_crowd` sheets already exist) and the ball-boy zombie
|
||
that catches your shots. Recorded so the ideas are not lost, not as a to-do.
|
||
11. **`ump_no_deal` does not exist.** Oliver asked for its removal; `ump_no_appeal` was removed as the
|
||
closest match. Worth one confirmation that no other line is still unwanted.
|
||
12. **Four sounds are missing and need RESEARCH first** (find/choose the source before implementing —
|
||
every existing sfx is either synthesised in `audio.js` or a wav in `assets/audio/`):
|
||
- the **ball machine** while it moves — a squeaking wheel/castor, so the furniture announces
|
||
itself. It already telegraphs its shot (`machWind` 0.9 s); this is the *approach* cue.
|
||
- **burning zombies** — fire is an epidemic now (`burnSpread`, `burnTime` 3 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 (`mgRate` 12/s during a horde, `mgPickupLen` 8 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.
|
||
|
||
---
|
||
|
||
# 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_RESTART` protects.
|
||
- **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_RESTART` exists to protect, and would end sessions rather than annoy.
|
||
|
||
1. **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 for
|
||
`autoAssist`): 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.
|
||
2. **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 `RES` 4 — 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=N` dance 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.
|
||
3. **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.
|
||
4. **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.py`
|
||
fills `promo/clips/`; `python3 tools/capture.py horde boss` shoots two; `python3 tools/capture.py
|
||
my-shots.json` shoots another list entirely; `--seed 77` re-rolls every take. What to shoot lives
|
||
in `tools/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 roster `diversity` walks, the ten bosses
|
||
by chapter, and the traps (a serve tin is spent in ~4 s of autopilot fire; `movement` fights the
|
||
match's own auto-run legs, so a zombie-tennis scene leaves it 0).
|
||
`tools/clips.js` is the engine that knows how to shoot any of it: `tools/capture.py` serves 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 real `axisX`. The two pilots are deliberately
|
||
mediocre (the tennis one IS the reachability soak's from `test.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`, and `resizeTo` did nothing; the frame is pinned instead by giving the canvas a
|
||
phone-sized CSS box, which `render.js` measures (`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.lowPower` defaults ON and caps `RES` at 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=3`
|
||
the backing store IS the output and no filter ever resamples a pixel.
|
||
- **Audio has no master bus** — every voice in `audio.js` connects straight to `ac.destination` —
|
||
so the rig makes `destination` itself a gain node fanning to the speakers AND to a
|
||
`MediaStreamDestination`, patched on `BaseAudioContext.prototype` (not `AudioContext`'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.js` reads `localStorage.court` once 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 `/done` when the list is empty.
|
||
`tools/` is in neither the Dockerfile COPY nor the image, and `promo/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
|
||
ffmpeg `drawtext` needs a font file the repo does not have in a `drawtext`-friendly format.
|
||
|
||
5. **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, which `splatter-tennis.wuwi.one` never 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.
|
||
6. **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 `RES` 4 — 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.
|
||
|
||
7. **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.lastHit` already 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.js` is where `startNetMatch()` 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.py` is 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.
|
||
|
||
**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 `healPerDuel` with 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: `explode` only wounded `'r'`. This is what
|
||
`isBoss()` now exists to prevent, and it is the second time the same class of bug has shipped.
|
||
- The machine's `machHp` did nothing — `'m'` fell through every variant branch to the generic
|
||
one-shot fallback. It soaks like the brute now (3 balls, verified).
|
||
- The boss both scored a kill and fired its chapter card, so two code paths set "what happens next"
|
||
in the same frame.
|
||
- The machine gated the hand-clear, turning a priority decision into a chore.
|
||
- A boss death produced a shambler-sized gore burst (`GORE_MUL` had no `'B'`; now 6 → ~305 particles).
|
||
|
||
## Regression net after steps 4-5
|
||
30 tapped tennis points = **50 hits / 0 whiffs** (unchanged — tennis is untouched by any of this).
|
||
45-game splatter soak: no exceptions, **all four chapters fired [1,2,3,4]**, machines appeared, ranged
|
||
hits landed, `mgT` drains, gore 40 / piles 30 / bodies capped at 34.
|
||
|
||
---
|
||
|
||
# CLOSED — the 50-game plateau, steps 1-3 (2026-07-30)
|
||
|
||
### Racket zombie's HP bar removed
|
||
See invariants. Replaced by a one-per-run "IT HAS A RACKET / IT RETURNS YOUR WEAK SHOTS / ONLY A FULL
|
||
PULL WOUNDS IT" modal from `spawnRacketZombie`, gated on `G.seen.racket`.
|
||
|
||
### "Sometimes the racket zombie doesn't seem harmable" — it was real, not a feeling
|
||
Three of the five rack slots did **nothing at all** to him, silently:
|
||
`TRIPLE` side balls skipped him (`variant === 'r'` → continue), `GUN`/horde pellets skipped him, and
|
||
fire spread could not ignite him. Meanwhile `BOMB` deleted him outright ignoring hp. All fixed via the
|
||
new `TUNE.dmg*` table and `wound()`. Verified numerically: 9 pellets kill a 3 hp boss, one side ball
|
||
takes 1 hp and is consumed, a weak side ball does nothing (correct — the `zSplatQ` gate), a bomb takes
|
||
3 off, and he now catches fire from a burning neighbour and cooks to death in 3.6 s at
|
||
`bossBurnMul` 0.4.
|
||
- **`hitR(zb)` gives him a 0.7 m contact radius** (shambler 0.55, brute 0.75) — part of "unharmable"
|
||
was simply missing him.
|
||
- **Melee is still skipped, and that is correct, not an oversight.** The player is bounded at
|
||
`P_BOUNDS.zMin` = 13.1 and the boss holds `z <= COURT.svcFar` = 5.5, so they are always on opposite
|
||
sides of the net and `meleeRange` 1.7 can never span it. Don't "fix" an unreachable path.
|
||
- **A sub-threshold ball still passes through him on purpose** so `racketAI` can rally it back. That
|
||
rally IS the feedback that the pull wasn't hard enough, so there is deliberately no clank there.
|
||
|
||
### The director un-capped
|
||
Old vs new, verified in-browser (gap s / body cap / speed m/s / advanced share / boss hp):
|
||
|
||
```
|
||
gap cap speed nasty bossHp
|
||
g 1 2.59/2.78 3/4 0.79/1.75 0.03/— 3/3
|
||
g10 1.15/1.00 12/13 1.61/1.75 0.30/0.27 4/3
|
||
g12 1.03/1.00 13/14 1.79/1.75 0.36/0.42 4/3
|
||
g20 0.71/1.00 21/14 2.52/1.75 0.60/0.42 5/3
|
||
g30 0.52/1.00 24/14 2.73/1.75 0.75/0.42 6/3
|
||
g50 0.35/1.00 24/14 2.73/1.75 0.75/0.42 9/3
|
||
```
|
||
- The spawn gap is **hyperbolic** now (`zSpawnEvery / (1 + games * zSpawnAccel)`) so it keeps
|
||
tightening instead of hitting a floor; `zSpawnMin` is only a safety stop.
|
||
- **Known, deliberate early-game change:** shamble speed used to be TIME-based (`splatT / 70`) and
|
||
maxed out after 105 seconds of splatter — i.e. during game 2 — so it was never a progression lever
|
||
and every early shambler already walked at top speed. It is games-based now, which makes games 1-10
|
||
genuinely gentler than before. Oliver has not playtested that yet; if the on-ramp feels flabby the
|
||
knob is `zSpeedPerGame`.
|
||
- Hordes scale with sets banked (`hordeGrow`) — length AND spawn rate. **`TUNE.zHordeCap` = 34 is
|
||
required**: a horde deliberately ignores the director cap, and with growth a late wave asks for
|
||
~120 spawns where the old fixed 12 s x 2.5/s could only ask for 30. A 41-body court showed up in the
|
||
soak before the cap went in.
|
||
|
||
### Regression net after all of it
|
||
30 tapped tennis points = 50 hits / 0 whiffs (unchanged). 40-game splatter soak: no exceptions,
|
||
`mgT` drains to 0, gore 40 / piles 30 / bodies capped at exactly 34.
|
||
|
||
---
|
||
|
||
# CLOSED — Oliver's 35-game retest (2026-07-27), all fixed 2026-07-30
|
||
Kept because each one is a trap worth not re-digging. Every fix was verified in the browser via
|
||
`window.T`; the numbers quoted are from those runs.
|
||
|
||
### 1 + 6. Player frozen (and stuck mid-animation) during the between-game pauses — was HIGH
|
||
**Two fixes: the first was wrong.** The movement block — pose update included — lived inside
|
||
`if (G.phase === 'rally')`, so every 'over' beat and every `pretoss` left the player standing while
|
||
zombies walked at him. The `pretoss` case is issue 6: `endHorde` calls `setupPoint()`.
|
||
|
||
The first attempt widened the block to all of splatter except `'toss'`. Oliver vetoed it on sight:
|
||
*"the mechanic where I suddenly can move freely around feels broken.. I basically can never walk
|
||
around freely - also I could walk off the screen"*. He is right — free movement for five seconds
|
||
between points, in a game where you otherwise only move while a ball is live, reads as a bug. It is
|
||
reverted, and the rule is now in Settled feel decisions above.
|
||
|
||
What actually ships, three separate pieces:
|
||
- **The dead wait too.** `held = !!G.changeover` in `updateZombies` stops every zombie's `z` advance,
|
||
its x steering, and the eat check for the breather's duration. They still bleed, still burn, still
|
||
animate — they just stand there, because the umpire called time and it is still a tennis match.
|
||
This is Oliver's own second option and the characterful one (the boys mop, everyone waits).
|
||
- **The stuck pose.** `expirePose` only ever expired `swing`/`serve`; `'run'` was the pose nothing
|
||
reset, and `P.runT` stops advancing outside the movement block, so a beat that began mid-stride
|
||
welded him to one run frame. An `else if (P.pose === 'run')` on the movement block fixes it for
|
||
every phase at once.
|
||
- **The on-screen clamp** (see Settled feel decisions) — `xVis` already existed but was applied only
|
||
in auto-fire modes, so ordinary play could walk clean off the edge. Now it always applies.
|
||
|
||
**The path where this actually bit** — worth knowing, because a clear court makes it look impossible:
|
||
a changeover normally starts only after `G.clearing` has emptied the court, so nothing is walking.
|
||
But `resolveInterlude('O')` spawns **five fresh zombies** ("the dead lose their patience") and *then*
|
||
hands back to `startChangeover` via `G.afterDuel`. Lose a duel scheduled on a changeover beat and you
|
||
get five shamblers plus five seconds with no agency. Verified fixed: they advance 0.000 m across the
|
||
breather and resume 0.75 m/s the instant "Time." is called.
|
||
|
||
If the 5 s still drags on a phone, `TUNE.changeoverT` is the knob — it is now a safe, watchable beat
|
||
rather than a dangerous one, so shortening it is purely a pacing call.
|
||
|
||
### 2. The mop boy walks on during the 1:1 duel — was LOW
|
||
`sendBoy` now returns early on `G.interlude`, and `startDuel` sets `G.boy = null`. Note the ordering
|
||
that makes this safe: `resolveInterlude('P')` clears `G.interlude` **before** calling `deliverTin()`,
|
||
so the boy still comes out with the tin.
|
||
|
||
### 3. Triple serve: the two wing balls lob off at the wrong height — was MEDIUM
|
||
The diagnosis in the old text was right. `g: ball.g` is now copied onto each clone (both push sites:
|
||
`hitServe`'s triple fan and `dragServe`'s re-fire clone) and `zombies.js` integrates
|
||
`e.vy -= (e.g || TUNE.gravity) * dt`. Verified at full pull: clones carry g = 21.16 (2.3 × the 9.2
|
||
base) instead of 9.2, and centre and wing apex heights now match exactly (2.90 m both).
|
||
|
||
### 4. The umpire calls "Time" on pauses that aren't changeovers — was LOW
|
||
`G.changeover` is now the **seconds left**, not a flag (still truthy, so every `!G.changeover` guard
|
||
reads the same), and it counts itself down near the top of `update()`, calling `voice('time')` when it
|
||
hits zero. It no longer rides the generic `G.overT` expiry — anything that overwrote `overT` mid-
|
||
breather (a melee kill taking a game, say) used to make the umpire call time on a pause that was
|
||
never a changeover. Verified with a `fetch` spy on `assets/audio/time.m4a`: an unrelated `overT`
|
||
expiry at t=0.4 s says nothing and leaves 4.6 s on the breather; the line fires exactly once, when
|
||
the breather's own clock runs out.
|
||
|
||
### 5. The transition tutorial illustration confuses people — was MEDIUM
|
||
`assets/x_howto.png` regenerated (Gemini 3 Pro, edit mode with the old sheet as `--input`, then
|
||
`assets/_key.py … 240`). The curved arrow and thumbprint are gone; it is now a press ring at the top,
|
||
a straight vertical dashed track, two chevrons pointing DOWN, and a fingertip at the bottom — with the
|
||
yellow shot arrow flying the opposite way to the zombie, which is the inversion lesson. Aspect is
|
||
unchanged (240×321 vs 240×322), so the modal still draws it at 90×120 in portrait. Checked at that
|
||
drawn size, not at full resolution.
|
||
|
||
### 7. Aiming plays the toss animation; it should stay in the pretoss stance — was MEDIUM
|
||
`update()`'s forced `sv.pose = 'serve'` for `phase === 'toss'` is now gated on `!G.aim`: while the
|
||
slingshot is drawn the server holds the pretoss stance, and only `hitServe` puts him into the
|
||
toss/smash. Verified: plain tap → pose `serve`; drawn bow → pose `idle`.
|
||
|
||
### 8. Show the current game number persistently — was LOW
|
||
`GAME N` now sits permanently under the scoreboard, right-aligned to the board's width, on the same
|
||
row as the NEXT pips and in the same dim tone (`render.js`, just before the pips block). The 1.1 s
|
||
flash when a game arms is still there.
|