diff --git a/HANDOFF.md b/HANDOFF.md index 65ed409..6f47318 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -5,9 +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 after Oliver playtested the fixes. All eight retest issues are closed and -recorded at the bottom with what the fix was, because each one is a trap worth not re-digging. -Issue 1's first fix was **vetoed on feel and replaced** — read that entry before touching movement. +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. ## What this is SNES-style pseudo-3D tennis (Super Tennis reference) that transitions into a zombie splatter game @@ -61,6 +62,33 @@ zombie. Getting caught = `G.mode='eaten'` = permadeath, and the run summary is t 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. - **`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 @@ -203,6 +231,152 @@ Known generator quirks, all still true: --- +# NEXT UP — late-game depth (agreed with Oliver 2026-07-30) + +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. + +## Settled design decisions (do not reopen) + +- **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. + +## Step 4 — ranged attacks and player damage (NOT BUILT) + +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 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. + +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-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 every 10 games (NOT BUILT — Oliver's favourite) + +*"my favourite - we need spectacular new sprites for the bosses and his wounds"* + +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. + +- 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. + +## Parked (step 6) — do not build without asking + +- **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. + +--- + +# 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. diff --git a/config.js b/config.js index 5d5e747..5dede7d 100644 --- a/config.js +++ b/config.js @@ -25,11 +25,42 @@ export const TUNE = { aiReaction: 0.45, // s before the CPU reads your shot — well-aimed corners are winners autoAssist: matchMedia('(pointer: coarse)').matches ? 1.15 : 0, // auto-run speed factor (mobile) zSpeed: 0.7, // zombie shamble speed m/s - zSpawnEvery: 3.0, // s between spawns in game 1 — shrinks by zSpawnStep per finished game - zSpawnMin: 1.0, // spawn-gap floor late in a run - zSpawnStep: 0.22, // spawn-gap reduction per finished game - zMax: 3, // shamblers on court in game 1 (cap grows +1 per game to zMaxCap; 0 = spawns off, for tests) - zMaxCap: 14, // absolute shambler cap + // ---- THE DIFFICULTY DIRECTOR. Every dial below used to bottom out by about game 12, which is why + // a 50-game run stopped changing (games 12..50 were arithmetically identical). They are all keyed + // on G.runGames now and all keep moving; the ceilings that remain exist for perf or fairness, not + // because the curve ran out. See HANDOFF "The difficulty director". + // Calibrated to TRACK the old curve through roughly game 12 and then keep going, rather than to + // replace it: the first pass at this made games 1-30 easier, which is the opposite of the ask. + zSpawnEvery: 3.0, // s between spawns in game 1 + zSpawnAccel: 0.16, // hyperbolic tightening: gap = zSpawnEvery / (1 + games * this). g5 1.67s, + // g11 1.09s (old floored at 1.0 here and stopped dead), g20 0.71s, g30 0.52s + zSpawnMin: 0.35, // hard spawn-gap floor, safety only — reached around game 41 + zMax: 3, // shamblers on court in game 1 + zMaxPerGame: 0.9, // cap growth per finished game (was a flat +1 into a wall at game 11) + zMaxCap: 24, // absolute shambler cap — readability ceiling, reached ~game 24 + zSpeedPerGame: 0.13, // shamble-speed ramp per finished game. Was TIME-based (splatT/70) and hit its + // ceiling after 105 seconds of splatter, i.e. during game 2 — so shamble speed + // was never a progression lever at all. g12 matches the old top speed (1.75 + // m/s), and it climbs to 2.7 m/s by game 22. + zSpeedMax: 2.9, // cap on that ramp (top speed = zSpeed * 3.9). runnerVMax still caps runners. + nastyPerGame: 0.03, // share of spawns drawn from the advanced roster, per finished game + nastyMax: 0.75, // ...capped here (g14 matches the old 0.42, g25 hits the cap). The old mix was + // frozen at exactly 0.42 from game 12 to the end of the run. + hordeGrow: 0.2, // horde length AND spawn rate both scale by this per set already banked + zHordeCap: 34, // hard body ceiling DURING a horde. A horde ignores the director cap on + // purpose (that is the point of it), but hordeGrow means a late wave now tries + // for ~120 spawns where the old fixed 12 s x 2.5/s could only ever ask for 30, + // and a 40-body court on a 270px-wide portrait screen is mush, not menace. + racketHpPerGame: 0.12, // the racket zombie stiffens as the run goes on (floored into whole hp) + // ---- damage. Bodies with hp (racket boss 'r', brute 'g') take damage in these units, so a big + // boss is just a bigger hp number and every weapon stays proportional. Fractions are legal: they + // bank on zb.chip and only whole points come off hp (the wound SKINS switch on hp thresholds). + dmgBall: 1, // a splat-quality ball or serve + dmgSide: 1, // one TRIPLE side ball — used to pass straight through the boss doing nothing + dmgPellet: 0.34, // one machine-gun round — three chip a point off. Also used to no-op on the boss + dmgBomb: 3, // caught in a blast. Was an instant kill that ignored hp entirely + dmgFlame: 2, // a direct flame-ball touch (and it sets the boss alight, which it didn't before) + bossBurnMul: 0.4, // burnDps multiplier while a boss body cooks — it burns, just slower than a body changeoverT: 5.0, // s the changeover breather lasts (after odd games, as the rules demand) boyMopSpeed: 2.2, // m/s a MOPPING ball boy crosses at (a fetching one still sprints at 7.5) boyMopDwell: 1.8, // s he stands on a stain pushing the mop before moving to the next one diff --git a/gameplay.js b/gameplay.js index d6956bb..12326a8 100644 --- a/gameplay.js +++ b/gameplay.js @@ -442,7 +442,8 @@ function startHorde() { if (G.horde) return; G.changeover = false; // a cheat-forced horde mid-changeover must not leave spawns locked G.pendingGame = false; // the horde IS the announcement - G.horde = { t: TUNE.hordeLen, spawnT: 0, mgT: 0 }; + const grow = 1 + (G.sets[0] + G.sets[1]) * TUNE.hordeGrow; // each set banked makes the next wave worse + G.horde = { t: TUNE.hordeLen * grow, spawnT: 0, mgT: 0, rate: TUNE.hordeRate * grow }; ball.active = false; G.phase = 'rally'; // ball gone; 'rally' lets the movement block steer P P.pose = 'idle'; P.poseT = 0; P.swingT = -1; // clear the triggering shot's serve/swing pose P.z = HOME.P.z; // brace at the baseline — the horde is a hold-the-line stand (finger steers x only) diff --git a/index.html b/index.html index 0462428..7582349 100644 --- a/index.html +++ b/index.html @@ -60,6 +60,6 @@
TAP TO START
- +