Steps 4 and 5 of the late-game depth plan. The ranged channel. A rogue ball machine gets wheeled onto the far court every four games: it never walks, never crosses your baseline and never scores, so it is a standing threat you choose when to deal with — the target-priority decision the game was missing. Its shot is a real arc solved like a serve, not a flat bolt, so it reads coming in and the swing window has something to meet. Three outcomes: a swing inside the volley window returns it as your own ball flying up-court (an extras clone, so "a parry is a weapon" cost nothing to build), the panic swish bats it away, and letting it reach you costs blood. The swish path is load-bearing, not politeness: in pretoss the player is rooted and cannot swing at all. Contact still kills outright — that stays a separate channel, and killPlayer() is now the single death path for both causes so the run summary and the best-run write cannot drift. Health is drawn on the man: procedural spatter at fixed anchors, a live trickle, and a red wash from the screen edges. No bar. Healing is winning a duel, which finally gives the duel a reason to be wanted. Chapter bosses every ten games, with their own sheet — a colossal zombie groundskeeper hauling a ball hopper, coming apart through arm-off, ribs-out and headless-still-throwing. Chapter 1 is 10 hp and 2.7 m, chapter 4 is 34 hp and 3.67 m. It outranks the set and duel beats at a clear, because a boss walking into a standing wave is unreadable. And it fixes the economy: a chapter-4 boss costs 12 bombs or 100 rounds or 34 side balls against a per-type cap of 9, so no single weapon can take one and a banked mixed arsenal is mandatory. The racket zombie is demoted to an occasional opponent, as asked — appearing every won game is exactly why he read as the boss. Five silent bugs found on the way, all in HANDOFF: a blast deleted a 34 hp boss outright (isBoss() exists to stop that class recurring), the machine's hp did nothing because it fell through to the one-shot fallback, the boss both scored and fired its card, the machine gated the hand-clear, and a boss died with a shambler-sized gore burst. Tennis untouched: 30 tapped points still 50 hits / 0 whiffs. 45-game soak clean with all four chapters firing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
473 lines
35 KiB
Markdown
473 lines
35 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-30. The **late-game depth work is complete**: steps 1-5 of the plan agreed after
|
||
Oliver's 50+ game playtest are all built and verified in-browser. Step 6 (crowd turning, ball-boy
|
||
catcher) is PARKED by his decision. **Awaiting his phone playtest of the boss/ranged build.**
|
||
What is still owed is art, not mechanics — see "Art still owed" at the bottom.
|
||
|
||
## What this is
|
||
SNES-style pseudo-3D tennis (Super Tennis reference) that transitions into a zombie splatter game
|
||
while keeping every tennis aesthetic (umpire voice, 15/30/40 scoring, ball boys, applause).
|
||
Zero-dependency vanilla JS, native ES modules, no build step. Mobile portrait one-handed play is
|
||
the primary target.
|
||
|
||
- **Run locally**: `preview_start` name `splatter-tennis` (python http.server :8641, `.claude/launch.json`)
|
||
- **Deploy**: push to `git@gitea.wuwi.one:wuwi/splatter-tennis.git` (main) → Coolify webhook builds
|
||
the Dockerfile (nginx, no-cache). Oliver tests on his phone at splatter-tennis.wuwi.one ~30s after
|
||
a push. **Bump `main.js?v=N` in index.html every time** or phones keep the old build.
|
||
The Dockerfile must also COPY any new top-level file (it currently ships `index.html`,
|
||
`manifest.webmanifest`, `*.js`, `assets/`).
|
||
- **Oliver edits files himself** (tuning values, sometimes sprites). ALWAYS Read current file state
|
||
before editing; never assume your last version is what is on disk.
|
||
|
||
## Module map (all in repo root)
|
||
`config.js` TUNE (live T panel) + structural constants + `UI` palette · `state.js` the `G` object +
|
||
P/O/ball/score + `setupPoint`/`sendBoy` · `gameplay.js` ballistics/swings/serve/rules/AI/`update()` +
|
||
transition + `splatPoint` + ball-boy routines · `zombies.js` zombie/gore/particle state +
|
||
`updateZombies` · `render.js` camera/palette-lerp/sprites/all drawing · `input.js` keys/touch/flick ·
|
||
`audio.js` sfx + recorded umpire (`assets/audio/*`, macOS `say -v Daniel`) · `panel.js` the T panel ·
|
||
`main.js` boot, loop, umpire-tap cheat, `window.T` test surface.
|
||
Two runtime-only import cycles (documented in headers): input↔gameplay, zombies↔gameplay.
|
||
|
||
## How the game flows
|
||
Tennis → win a game → **transition** (`G.mode='transition'`, timeline on `G.transT`) → **splatter**.
|
||
In splatter you serve at zombies; `ball.pow` ≥ `TUNE.zSplatQ` splatters, below that it staggers.
|
||
Tennis scoring keeps running. `TUNE.killsPerPoint` kills = one point; games/sets accumulate;
|
||
6 games = a set = a **horde** (racket auto-fires, finger steers you absolutely).
|
||
Odd games end in a **changeover** breather. A won game leaves the wave standing — you must
|
||
**clear it by hand** (`G.clearing`) before play advances (`proceedAfterClear`).
|
||
Every **10 games a CHAPTER BOSS** walks on (`G.bossFight`) — it clears the stage, blocks all spawns,
|
||
and volleys at you. Every 4 games a **rogue ball machine** is wheeled out. Both attack the second
|
||
damage channel: `shots[]` chip `G.hp`, which only a won duel heals. Contact is still instant death.
|
||
Weapons are **only ever won**, in the **sudden-death duel** (`G.interlude`) against the racket
|
||
zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixture he used to be). Getting caught = `G.mode='eaten'` = permadeath, and the run summary is the whole game.
|
||
|
||
## Architecture & invariants — know these before touching anything
|
||
|
||
- **`G.modal` is the only thing that stops the world.** `update()` early-returns on it (after
|
||
flash/card decay), so no zombie steps, no ball flight, no spawn timers — only `idleT` ticks for
|
||
the blink. `showModal`/`dismissModal` in gameplay.js; `drawModal` is the LAST thing render draws.
|
||
Dismissal must **swallow the gesture**: input.js `touchstart` returns on it, keydown returns on
|
||
it, and the `pointerdown` dismisser is registered **only on `pointer: fine`** — on touch,
|
||
pointerdown fires *before* touchstart, so a shared handler cleared the modal and let the
|
||
touchstart underneath serve. `G.seen.<key>` gates first-time-only modals.
|
||
**`m.in` (0..1 over `TUNE.modalIn`) is the arcade entrance** and the ONE thing that animates behind
|
||
the freeze — it is ticked inside that same early-return, so it advances while nothing else does.
|
||
Icon and title fly in from the right (with a two-ghost speed streak), body lines cascade from the
|
||
left, all on `easeOutBack`; every x is `Math.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.
|
||
- **`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.
|
||
- **`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.
|
||
|
||
Known generator quirks, all still true:
|
||
- It sometimes ignores the magenta (harmless — the key uses the corner colour).
|
||
- It draws near-identical animation frames unless you describe strongly contrasted poses.
|
||
- **It adds props the A frames don't have** (a dragged pole on every brute B frame, a mop head). If
|
||
the prop is a separate blob, keep the largest connected component; if it touches the body, cut on
|
||
column density — a torso is dense, a dragged pole is a thin diagonal of ~7 px per column.
|
||
- **If frames merge** (a handle bridging the gap) `_slice.py` reports "found 1 frames". Re-prompt
|
||
for wider gaps first; if that fails, cut at the true column gaps plus the thinnest column inside
|
||
the merged pair, then common-scale.
|
||
- **Colour-match any regenerated frame** of an existing pair before shipping: fit a per-channel
|
||
linear map onto the original's mean and sd, then verify with a mean-difference number.
|
||
|
||
## Gotchas learned the hard way
|
||
- **Stale ES modules are the #1 time sink.** python http.server sends no cache headers, so an edited
|
||
module often loads STALE. `preview_stop`/`preview_start` only helps if the port actually changes,
|
||
and holding 8641 with a throwaway socket **did not work reliably**. What DOES work:
|
||
```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.
|
||
|
||
---
|
||
|
||
# ART STILL OWED (mechanics are done)
|
||
|
||
The only outstanding work on the boss/ranged build is art. Everything below is playable now.
|
||
|
||
- **The chapter boss has no B-frames for its wound stages.** `assets/_sheet_boss.png` is a 5-frame
|
||
sheet: `z_B1`/`z_B2` (a real 2-frame stride, driven off `zb.z / TUNE.bruteStride` per the
|
||
distance-not-time invariant) plus three static wound skins `z_Bw1` (arm torn off) / `z_Bw2` (ribs
|
||
out) / `z_Bw3` (headless, still throwing). The brute precedent has an A **and** B frame per wound
|
||
stage (`z_gw` + `z_gwb`, etc.), so a wounded boss currently stops striding mid-fight. Fix by
|
||
generating a second sheet of the three wound poses with the stride swapped, named `z_Bw1b`..`z_Bw3b`,
|
||
and extending drawZombie's stage pick the way the brute's does.
|
||
**Generating it: the model laid the first sheet out in TWO ROWS, which `_slice.py` cannot cut (it
|
||
segments on column gaps).** Don't fight the prompt — detect the blobs and re-flow them into one
|
||
baseline-aligned row before slicing. The reflow script is in this session's git history.
|
||
- **The ball machine is drawn procedurally** (`drawMachine` in render.js) — a hopper, a barrel and two
|
||
wheels, with the muzzle glowing hotter through the wind-up. That was a choice, not a stopgap: it is
|
||
furniture, a box is four rects, and the readable parts are the barrel and the charge. Replace with a
|
||
sheet only if it reads as a crate on a phone.
|
||
- **Chapter cards reuse `drawCard`,** which still cuts in with no entrance while modals now swoosh
|
||
(`TUNE.modalIn`). Giving the card a matching entrance was offered and not yet asked for.
|
||
|
||
# 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.
|