The rack had five hardcoded slots where the index WAS the weapon, spelled out in
five places: SLOTS, slotStock, WDESC, drawRack's three parallel arrays,
deliverTin's w+1, and bare wsel === 1|2|3 / G.weapon === 4 tests across five
files. config.WEAPONS is the one table now, G.rack holds up to four {id, n} in
the order you GATHERED them, and slot 0 stays BALL. Everything reads through
slotId/slotStock/slotEntry, so nothing else knows how the rack is stored.
A fifth weapon is always allowed, per Oliver: it rides slot 0 as G.temp until
spent, then slot 0 goes back to the infinite ball. A barrel arms on the spot and
is spent quickly, so refusing it would mean a barrel you shot did nothing. And a
spent weapon LEAVES its slot -- with gathered slots, an exhausted weapon holding
one is a quarter of the rack you can never pick up into. (The old rack kept its
empties dimmed, which was right when a slot meant "BOMB goes here".)
Four new weapons, and they are all the ONE existing pellet loop. firePellet(w)
stamps the WEAPONS row onto the round -- r, dmg, hp, pierce, boom, ignite, col,
speed, life -- so the beacon cannon, flamethrower, laser and rocket needed no new
array and no new update; the break after the first body was the only thing making
a pellet single-target. All four go through the same isBoss branch, which is why
none can silently no-op on a boss the way three of five once did. Measured:
gun/beacon/laser/rocket reach past 16 m, the flamer is a ~6 m cone that ignites.
G.mgT stays the mode timer for all of them, so the eighteen !(G.mgT > 0) guards
across five files did not have to change. kind:'burst' auto-fires at its own rate
for len seconds; kind:'shot' holds the same braced stance and fires one per tap.
The barrels reuse the ball machine's shape: a rolling, shootable non-body. All
three of the registrations that fail silently are done -- blocksClear (loot must
not gate the hand-clear), the contact guard (furniture cannot eat you), and
hitR/GORE_MUL/zombieH. It scores nothing.
The pink had to become procedural. The chroma key deletes every pixel where red
and blue both beat green by 40, so a magenta drum and a magenta laser pistol both
came back as hollow outlines -- 19% and 15% solid against 50-56% for the icons in
other colours. The drum is grey steel with hazard chevrons and every bit of its
pink is the halo drawBarrel paints; the laser went electric blue. Both are solid
now (92% and 58%), and the rule is written down in HANDOFF.
tinTriples 3 -> 10 as asked, and the four dead TUNE ammo constants are gone --
nothing read them once the table landed, which is a panel value you can tune all
day for no effect.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1331 lines
100 KiB
Markdown
1331 lines
100 KiB
Markdown
# HANDOFF — Splatter Tennis
|
||
|
||
**This file is the only planning document.** `PLAN.md` and `POLISH-PLAN.md` are deleted —
|
||
everything they specified is shipped, and what still matters from them is recorded below. For the
|
||
long history, `~/.claude/projects/.../memory/splatter-tennis-history.md` is auto-loaded; for the
|
||
detail of any individual change, read the git log.
|
||
|
||
Last updated 2026-07-31 (serve wind-up pass). **Start with "How to work on this", "OPEN — leftovers" and "LATER —
|
||
shipping it"** (all three just above the CLOSED sections) — the first tells you how Oliver's feedback
|
||
should be handled, the second is everything known to be outstanding in the game, the third is the
|
||
demo/store/campaign plan, which is decided but entirely unbuilt. All four playtest rounds are closed
|
||
below them, newest first. **Awaiting his next phone playtest.**
|
||
|
||
## What this is
|
||
SNES-style pseudo-3D tennis (Super Tennis reference) that transitions into a zombie splatter game
|
||
while keeping every tennis aesthetic (umpire voice, 15/30/40 scoring, ball boys, applause).
|
||
Zero-dependency vanilla JS, native ES modules, no build step. Mobile portrait one-handed play is
|
||
the primary target.
|
||
|
||
- **Run locally**: `preview_start` name `splatter-tennis` (python http.server :8641, `.claude/launch.json`)
|
||
- **Deploy**: push to `git@gitea.wuwi.one:wuwi/splatter-tennis.git` (main) → Coolify webhook builds
|
||
the Dockerfile (nginx, no-cache). Oliver tests on his phone at splatter-tennis.wuwi.one ~30s after
|
||
a push. **Bump `main.js?v=N` in index.html every time** or phones keep the old build.
|
||
The Dockerfile must also COPY any new top-level file (it currently ships `index.html`,
|
||
`manifest.webmanifest`, `*.js`, `assets/`).
|
||
- **Oliver edits files himself** (tuning values, sometimes sprites). ALWAYS Read current file state
|
||
before editing; never assume your last version is what is on disk.
|
||
|
||
## Module map (all in repo root)
|
||
`config.js` TUNE (live T panel) + structural constants + `UI` palette · `state.js` the `G` object +
|
||
P/O/ball/score + `setupPoint`/`sendBoy` · `gameplay.js` ballistics/swings/serve/rules/AI/`update()` +
|
||
transition + `splatPoint` + ball-boy routines · `zombies.js` zombie/gore/particle state +
|
||
`updateZombies` · `render.js` camera/palette-lerp/sprites/all drawing · `input.js` keys/touch/flick ·
|
||
`audio.js` sfx + recorded umpire (`assets/audio/*`, macOS `say -v Daniel`) · `panel.js` the T panel ·
|
||
`main.js` boot, loop, umpire-tap cheat, `window.T` test surface.
|
||
THREE runtime-only import cycles (documented in headers): input↔gameplay, zombies↔gameplay,
|
||
and state↔zombies (`sendBoy` reads `gore` to decide whether a mopping round is worth walking out for).
|
||
All three are safe for the same reason: the imported binding is only ever touched inside a function at
|
||
runtime, never during module evaluation.
|
||
|
||
## How the game flows
|
||
Tennis → win a game → **transition** (`G.mode='transition'`, timeline on `G.transT`) → **splatter**.
|
||
In splatter you serve at zombies; `ball.pow` ≥ `TUNE.zSplatQ` splatters, below that it staggers.
|
||
Tennis scoring keeps running. `TUNE.killsPerPoint` kills = one point; games/sets accumulate;
|
||
6 games = a set = a **horde** (racket auto-fires, finger steers you absolutely).
|
||
Odd games end in a **changeover** breather. A won game leaves the wave standing — you must
|
||
**clear it by hand** (`G.clearing`) before play advances (`proceedAfterClear`).
|
||
Every **10 games a CHAPTER BOSS** walks on (`G.bossFight`) — it clears the stage, blocks all spawns,
|
||
and volleys at you. Every 4 games a **rogue ball machine** is wheeled out. Both attack the second
|
||
damage channel: `shots[]` chip `G.hp`, which only a won duel heals. Contact is still instant death.
|
||
Weapons are **only ever won**, in the **sudden-death duel** (`G.interlude`) against the racket
|
||
zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixture he used to be). Getting caught = `G.mode='eaten'` = permadeath, and the run summary is the whole game.
|
||
|
||
## Architecture & invariants — know these before touching anything
|
||
|
||
- **`G.modal` is the only thing that stops the world.** `update()` early-returns on it (after
|
||
flash/card decay), so no zombie steps, no ball flight, no spawn timers — only `idleT` ticks for
|
||
the blink. `showModal`/`dismissModal` in gameplay.js; `drawModal` is the LAST thing render draws.
|
||
Dismissal must **swallow the gesture**: input.js `touchstart` returns on it, keydown returns on
|
||
it, and the `pointerdown` dismisser is registered **only on `pointer: fine`** — on touch,
|
||
pointerdown fires *before* touchstart, so a shared handler cleared the modal and let the
|
||
touchstart underneath serve. `G.seen.<key>` gates first-time-only modals.
|
||
**`m.in` (0..1 over `TUNE.modalIn`) is the arcade entrance** and the ONE thing that animates behind
|
||
the freeze — it is ticked inside that same early-return, so it advances while nothing else does.
|
||
Icon and title fly in from the right (with a two-ghost speed streak), body lines cascade from the
|
||
left, all on `easeOutBack`; every x is `Math.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.
|
||
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 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.
|
||
- **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()` — the one beat where the court is guaranteed empty. The game beat it
|
||
interrupts is parked in `G.afterDuel` and resumed when the duel resolves.
|
||
- **Anything that blocks serving.** If a "can't serve" report ever comes in, check these first —
|
||
each is an early return in `keyPressed`: `G.mgT`, `G.horde`, `G.changeover`, `G.interlude.introT`,
|
||
`G.modal`. `G.mgT` in particular must keep counting down in EVERY phase (it used to tick only
|
||
during `rally`, so a duel calling `setupPoint()` stranded it above zero forever = permanent lock).
|
||
- **Animation frames step off DISTANCE COVERED, never elapsed time** — `boy.dist` for ball boys,
|
||
`zb.z / TUNE.bruteStride` for brutes. Time-driven cycles moonwalk the moment speed and frame rate
|
||
disagree. Sprite heights follow the PNG (`img.height / SPRITE_H`) because `_slice.py`
|
||
common-scales a sheet; a wound stage's height must ALWAYS come from its A frame or it pops.
|
||
- **Fire chains.** `zb.burnT`/`burnD`; `ignite()` is read-only over `zombies[]` so it is safe inside
|
||
a kill loop; `burnTick()` returns true when the fire killed the body and the caller MUST
|
||
`continue`. The flame ball also ignites what it FLIES PAST (`TUNE.burnBallR`) — without that its
|
||
full pierce killed its own lane outright and nothing was left to catch. Flames are drawn
|
||
procedurally (`drawFlames`), no sprite sheet.
|
||
- **Any loop over `zombies[]` whose body can kill** can cascade into a sweep and gut the array
|
||
mid-iteration — keep the `if (!zb) continue` guards and iterate backwards.
|
||
- **The ball boy is ONE slot.** `sendBoy` never replaces a boy already on court (a kill streak used
|
||
to teleport him back to the wing several times a second); new work is appended via `jobs`.
|
||
`mopRound` walks him to a stain, holds him there for `TUNE.boyMopDwell` while the push cycle
|
||
animates in place, then moves him on. Three sheets: `x_boymop` (stopped, working),
|
||
`x_boycarry` (walking with the mop shouldered), `x_boyrun` (tennis-mode fetch).
|
||
- **Crowd applause is a real two-frame swap** (`x_crowd` seated / `x_crowd2` arms up), per column on
|
||
its own phase, with **no vertical offset**. A fractional `dy` on a scaled image re-snaps its pixel
|
||
rows every frame under nearest-neighbour — that boiling beside the HUD was the "scoreboard
|
||
flicker" (the board art is 99.9% opaque; nothing was ever showing through it).
|
||
**If you touch one crowd frame you must apply the identical curve to the other**, or the swap
|
||
starts flashing brightness. `assets/_sheet_crowd1.png` is the pre-brightening original.
|
||
|
||
## Settled feel decisions — do not regress silently
|
||
- **Two-stage serve**: touch = reticle, drag = aim, release = fire, drag back under `pullMin` =
|
||
cancel. The pull's DIRECTION picks the lane, its LENGTH is power — deliberately decoupled so a
|
||
far shambler never costs a longer drag than the screen has.
|
||
- **The manual wave clear** after a won game (`G.clearing`). Oliver was emphatic.
|
||
- **Thin, dynamic blood.** Open wounds gush live particles (`bleed()`); blood is never painted into
|
||
a sprite. Gore piles/stains are the loud part; keep the trickle sparse.
|
||
- **One gesture means one thing** on touch. No mode where a drag silently becomes something else.
|
||
- **Volleys**: pre-bounce contact gets reach ×1.4 / window ×1.8 (`VOLLEY`). A RISING ball is
|
||
hittable at any height; only a dropping ball below `CONTACT.minY` is past you.
|
||
- **Aim magnet**: `flickTarget` picks the best-ALIGNED body, with `TUNE.aimTie` of slack in which a
|
||
nearer one still wins, so a straight-ahead lane still eats the nearest body first.
|
||
`aimTie: 1` restores the old "nearest along the lane always wins" for A/B.
|
||
- `TUNE.fps` 60 — 30 was vetoed on feel.
|
||
- **The player only walks during `rally`.** Never between points. Letting him walk during the 'over'
|
||
beats and `pretoss` was shipped once and vetoed immediately: *"I basically can never walk around
|
||
freely"* — so suddenly being able to, only between points, reads as the game breaking rather than
|
||
as a fix. Frozen-with-danger-approaching is fixed by holding the DEAD instead (`held` in
|
||
`updateZombies`), never by handing back movement.
|
||
- **The player must always be ON SCREEN.** `P_BOUNDS.x` is 6.5 m but portrait only shows about
|
||
±4 m either side of the centre line at the baseline, so the walk bound alone let him leave the
|
||
frame entirely. `xLim = Math.max(xVis, COURT.W / 2)` — the camera limit, floored at the sideline so
|
||
every legal ball stays catchable.
|
||
|
||
## Verification recipe (the regression net)
|
||
Drive the game deterministically via `window.T` with `javascript_tool`. The Browser-pane eval
|
||
context reports `document.visibilityState === 'hidden'`, so RAF is paused; halt it anyway with
|
||
`window.requestAnimationFrame = () => 0` and step with `T.step(1/60)`. `T.press('Space')` = tap.
|
||
Clear the intro first: `document.getElementById('intro')?.remove()`.
|
||
- **You MUST set `T.TUNE.autoAssist = 1.15` first** — the Browser pane reports `pointer: fine`, so
|
||
the mobile assist is off and every ball reads as "never reachable".
|
||
- Force splatter with `T.forceZombies()` then ~600 steps (in splatter it forces a HORDE instead).
|
||
- `T.zombies`, `T.spawnZombie(x, z, 'a')`, `T.TUNE.zMax = 0` to stop spawns during tests.
|
||
- Also exposed: `T.explode`, `T.splatPoint`, `T.startDuel`, `T.resolveInterlude`, `T.deliverTin`,
|
||
`T.flickTarget`, `T.showModal`/`T.dismissModal`, `T.render`.
|
||
|
||
**Benchmarks that must keep holding** (re-run after any physics/aim change):
|
||
- CPU returns ~11/12 serves (91.7%). Recent 150-sample runs: 90.7%, 89.3%, 90.7%, 88.7% (±2.5%).
|
||
Watch this — the last few sit under the bar rather than scattered around it.
|
||
**The harness for this number was never written down and I could not reconstruct it.** A rig that
|
||
counts "did the CPU get a racket on the served ball" scores 100/100, because after a flub the CPU
|
||
gets another swing at the SAME ball (`aiSwingCool = 0.5`) and connects — 91.7% ≈ 1 − aiFlub, so the
|
||
recorded number must count only the FIRST swing opportunity, or only returns that land in.
|
||
Until someone writes the rig down, prove "no tennis regression" **differentially** instead: run
|
||
your rig, `git stash` the change, reload the modules, run the identical rig, compare. Equal
|
||
numbers on the same rig is the real claim; a bare percentage against this line is not.
|
||
- 30 human-tapped points (tap when the incoming ball is 2.2–3.2 m away) = **50 hits / 0 whiffs**.
|
||
This one DOES reproduce exactly — drive it off `G.missLog` (HIT vs MISS counts).
|
||
- A 26-game state-machine soak with bombs/bursts/fire must finish with no exceptions, no stall,
|
||
`mgT` back to 0, and gore/piles arrays bounded (gore caps at 40, piles at 30).
|
||
**Two ways a soak lies to you**, both cost me a run: force-spawning zombies during `G.clearing`
|
||
makes the hand-clear un-completable and reads as a 30-minute stall; and reviving the player after
|
||
each `eaten` inflates gore past its cap, because the death push (`zombies.js`, the `eatenR` hit) is
|
||
the one uncapped `gore.push` in the file — fine at one death per run, not at 260. Respect
|
||
`clearing`, and either end the soak on death or set `TUNE.eatenR = 0` to make him untouchable.
|
||
- **Never trust a single sample.** aiFlub is 9%; a 20/24 run is normal noise. Use 150.
|
||
|
||
## Asset pipeline
|
||
1. `generate-image` skill (needs `OPENROUTER_API_KEY` in the gitignored repo `.env`; run with the
|
||
scratchpad venv python — system python lacks `requests`). Passing the existing sheet as
|
||
`--input` (edit mode) nails the style and is how every matching sheet here was made.
|
||
2. Ask for a **magenta background** and, for sheets, "at least 150px of empty magenta between
|
||
frames" plus "the prop must stay inside its own frame".
|
||
3. `python3 assets/_slice.py sheet.png out1.png ...` — chroma-key on the corner colour, magenta
|
||
de-fringe, column-gap segmentation, common-scale per sheet, `IDLE_TARGET_H=112`. `--largest`
|
||
keeps only the main blob.
|
||
4. Standalone art (logo, popup panels) uses `assets/_key.py` instead: chroma-key + crop to the
|
||
content bbox + optional downscale, no frame splitting, no forced height.
|
||
5. Register new names in `render.js`'s `SPR` preload array.
|
||
6. **New sprite not showing up? Bump `SPRV` in render.js.** Sprite loads go through
|
||
`assets/<n>.png?v=SPRV` for the same reason index.html versions main.js: python http.server sends
|
||
no cache headers, so the browser caches sprite responses heuristically — **including 404s**. Add a
|
||
name to the `SPR` list before its PNG exists, load the page once, and that browser treats the
|
||
sprite as permanently missing while `drawZombie` silently falls back to another design. Bump SPRV
|
||
alongside `main.js?v=N`.
|
||
7. **When a scripted `str.replace` edit is meant to rewire logic, ASSERT it matched.** A silent
|
||
no-op replace left `drawZombie` on its old hardcoded boss names while everything else had moved to
|
||
the BOSS_ART table, and the built-in fallback then hid the failure perfectly — all four boss
|
||
designs rendered as design 1 and nothing errored. Cost a full debugging round. `drawImage`
|
||
instrumentation is what finally settled it:
|
||
```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.
|
||
|
||
## 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 three-stage machine**, not a screen per DOM node: `#intro[data-stage]` swaps
|
||
`splash -> {courts, tutorial}` 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; `START GAME` and `BACK` are their own
|
||
buttons. The tutorial is a data table (`TUT` in main.js) of Marvin poses, an optional picture in
|
||
`assets/`, and his lines — adding a page is a row.
|
||
|
||
**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.**
|
||
|
||
**Menu and tutorial 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:
|
||
|
||
- **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
|
||
- **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.
|
||
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 2026-07-31 with Oliver. **None of this is built.** Recorded here so the
|
||
decisions survive; the ordering below is roughly the order to do it in.
|
||
|
||
**The model: paid, $2.99, no ads.** Both stores. No ad SDK, no consent/CMP flow, no ATT prompt, no
|
||
tracking — which keeps the store card at "Data Not Collected", keeps review trivial, and keeps a
|
||
zero-dependency project zero-dependency. *Maybe* later: a free build capped at chapter 1 with an IAP
|
||
to unlock all chapters. That is a fallback, not the plan — paid→free is a normal move, free→paid is
|
||
not, so launch paid.
|
||
**Ads are rejected**, with one exception held in reserve: rewarded video on the CHALLENGE (below),
|
||
never an interstitial. An interstitial after death would land exactly in the "one more run" gap that
|
||
`EATEN_RESTART` exists to protect, and would end sessions rather than annoy.
|
||
|
||
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.
|
||
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) probably has to stop being under-maintained before a store review.
|
||
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.
|
||
A capture helper on `window.T` to jump straight into a given beat (the transition timeline is on
|
||
`G.transT`) would make clean takes cheap — not yet built.
|
||
|
||
**Expectations, so nobody is surprised:** with no marketing, 20-100 units in year one is the honest
|
||
central estimate (~$40-210 net, i.e. under Apple's fee at the low end). The monetization model is
|
||
worth maybe $200 either way; the video channel is worth ~100x that. Ship paid because it is cheap and
|
||
protects the design, not because it will earn.
|
||
|
||
**Explicitly NOT to be built** (these fight settled decisions, see the invariants above):
|
||
- **selling weapons or tins** — weapons are *only ever won*, in the duel; a shop deletes the duel's
|
||
reason to exist and `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.
|