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:
1 parent
ffac9b5512
commit
4a066e5cc6
14 files changed
+492
-131
No files matched your search
+101
-82
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in new issue
Block a user