Add chapter bosses and a second damage channel; demote the racket zombie

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>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-07-30 12:55:35 +02:00
1 parent ffac9b5512
commit 4a066e5cc6
14 files changed
+492 -131

No files matched your search

+101 -82
View File
@@ -5,10 +5,10 @@ everything they specified is shipped, and what still matters from them is record
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, mid-way through the **late-game depth work** after Oliver's 50+ game
playtest. Steps 1-3 of that plan are DONE; **steps 4 and 5 are the next job and are specified in
full at the bottom of this file** — start there. Everything from the earlier retest rounds is closed
and recorded below it.
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
@@ -41,8 +41,11 @@ Tennis scoring keeps running. `TUNE.killsPerPoint` kills = one point; games/sets
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. Getting caught = `G.mode='eaten'` = permadeath, and the run summary is the whole game.
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
@@ -89,6 +92,36 @@ zombie. Getting caught = `G.mode='eaten'` = permadeath, and the run summary is t
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
@@ -231,97 +264,83 @@ Known generator quirks, all still true:
---
# NEXT UP — late-game depth (agreed with Oliver 2026-07-30)
# ART STILL OWED (mechanics are done)
He has played 50+ games. Verdict: **nothing new happens after about game 30.** The measured cause is
in the invariants above — every difficulty dial bottomed out by game 12. Steps 1-3 removed the flat
line; **steps 4 and 5 are the actual content and are NOT built yet.** Step 6 is parked by his
decision. Read this whole section before starting: the decisions in it were argued out and settled,
and re-litigating them wastes his time.
The only outstanding work on the boss/ranged build is art. Everything below is playable now.
## Settled design decisions (do not reopen)
- **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.
- **Contact still kills outright. This was explicitly decided.** Player HP is a SEPARATE channel that
only ranged attacks touch. A shambler reaching you is still `G.mode = 'eaten'`, still permadeath,
still the whole run over. Oliver: *"keep the instant death separate"*. The reason: two threats that
demand two different responses is the depth; making contact chip instead of kill would make the game
easier and lose the dread that carries it.
- **Player health is his SHIRT, not a bar.** Tennis whites going progressively bloodier, plus
`bleed()` on the player once wounded. No HUD box, no meter, no number — same reasoning that removed
the boss's HP bar (see invariants). This is the aesthetic rule for this game: state is expressed in
the art or in tennis furniture, never in floating UI.
- **Healing happens by winning a sudden-death duel.** That finally gives the duel a reason to be
WANTED rather than just being a weapon vending machine.
- **Step 6 is parked.** The crowd turning into zombies and the ball-boy-zombie-who-catches-your-balls
were both discussed; Oliver: *"for 6 that's my least favourite one and we should park it"*. Do not
build them. They are recorded at the bottom only so the idea isn't lost.
# CLOSED — chapter bosses + ranged attacks, steps 4-5 (2026-07-30)
## Step 4 — ranged attacks and player damage (NOT BUILT)
## Step 4 — the ranged channel
The point of this step: it is the only idea on the list that adds a **new verb**. Every other option
adds more of what the player already does. The player's whole vocabulary today is pull-aim-fire,
panic-swish, pick a tin, walk during a rally.
**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 cheap insight to build on: a thrown projectile IS just a ball hit by the other side.**
`trySwing` already implements "an incoming ball, a generous volley window, time it or eat it", and
`VOLLEY` already widens reach ×1.4 / window ×1.8 for a pre-bounce contact. A thrower is therefore
much closer to "a racket zombie that serves at you" than to a new system. Reuse that path rather than
writing a projectile-dodging mechanic from scratch.
**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.
Deliver it as the **rogue ball machine** — Oliver's own idea and the most on-brand thing in the list,
because a ball machine is *real tennis furniture*. Suggested shape:
- A new variant (`'m'`?) that does NOT shamble: it parks on the far court and fires at you on a cycle.
- It must **telegraph**: a wind-up pose plus a sound, far enough ahead that a phone player with one
thumb can react. Reuse the `shriek()` fairness-contract pattern the runner already uses.
- Returning the shot is a normal swing/volley. Missing it costs one point of player HP and starts the
bleed. It must never be an instant kill — that is what contact is for.
- Because it doesn't walk, it never reaches your baseline, so it can't score for THEM by crossing —
it has to be a standing threat you choose when to deal with. That makes it a target-priority
decision, which is new.
**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.
Player-state plumbing this needs (none of it exists yet):
- `G.hp` / `G.hpMax` on the `G` object in state.js, reset per run.
- The bloodied-shirt skins: new `p_*` sprites per wound stage, resolved in `drawChar` exactly the way
`drawZombie` resolves boss skins — **as a fraction of hpMax, not against literal hp numbers**
(see invariants; this trap has already been hit once).
- `bleed()` currently takes a zombie; it will need to work for the player, or get a sibling.
- Healing in `resolveInterlude('P')`, alongside `deliverTin()`.
- The eaten/run-summary path must still work when death comes from HP reaching zero rather than from
contact — check `G.eatenAt`, `playerGore()`, and the `localStorage` best-run write.
## Step 5 — chapter bosses, and the racket zombie demoted
## Step 5 — chapter bosses every 10 games (NOT BUILT — Oliver's favourite)
**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).
*"my favourite - we need spectacular new sprites for the bosses and his wounds"*
**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.
Why this is worth the most: it gives the run **chapters**, and it fixes a real economy problem —
tins arrive every 3 games forever (`duelFirst` 2, `duelEvery` 3), so there is currently no reason to
bank anything. A rare wall you must save weapons for is what turns the rack into a decision.
**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.
- Every 10 games. Escalating each time: more hp, bigger, more wound stages, and — once step 4 exists
— it uses ranged attacks too, so escalation becomes numbers rather than new mechanics.
- **The damage model is already ready for this.** `wound()` + `hitR()` + the `TUNE.dmg*` table mean a
boss is just a bigger `hp` number and every weapon stays proportional. Verified: a 9 hp body takes
3 off per bomb, ~3 pellets per point, 1 per side ball. Do not add per-boss damage special cases.
- **Bigger is free**: the brute already renders at 2.7 m against a shambler's 1.8 m, so sprite scale
is an existing, proven lever.
- **The always-present racket zombie must stop being "the boss" first**, or a real boss will not read
as special. Today `splatPoint` respawns one every single game you win. Decide: does the mini-boss
become rarer, or get renamed/re-skinned as an ordinary "opponent" while the chapter boss is a
distinct thing?
- Sprite work is the bulk of this step and it is Oliver's stated priority. Follow the asset pipeline
section above exactly: `generate-image` in EDIT mode with the existing sheet as `--input` is what
makes a new sheet match, magenta background, then `_slice.py` (frames) or `_key.py` (standalone).
Wound stages must be generated as a SET so they colour-match — and remember a wound stage's height
must always come from its A frame or it pops.
`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.
## Parked (step 6) — do not build without asking
## Bugs found and fixed while building this (all were silent)
- **The crowd turning.** Mechanically it is "spawn from the stands instead of the far baseline", i.e.
attacks arriving from behind and beside you, which breaks the one assumption the whole game rests
on. Highest spectacle-per-line available, and `x_crowd`/`x_crowd2` already exist. Oliver's least
favourite; parked.
- **The ball-boy zombie that catches your balls.** Denies your best lane, forcing target priority.
Parked with the above.
- 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.
---