Eight bodies on the card, and the umpire gets his voice back

THE FLICK, again. "i can barely reach the extremes of the court." It could
reach 3.3 m against a 4.115 m line — safe, and too safe. TUNE.flickWidth is
3.95 now (96% of the way to the corner, 17 cm to spare) with flickReach 0.42,
which keeps the guarantee intact: reach x timing + scatter stays inside the
line at EVERY quality, worst case 4.06. 20% more reach, still impossible to
send out by aiming.

A FORWARD FLICK IS A SMASH. Up-screen is the far court, so flicking into it
asked for depth and nothing else — a direction with no consequence behind it.
It buys pace now (smashBoost, scaled by timing). Deliberately NOT distance:
solveShot still lands it on the same spot, so the shot arrives flatter and
sooner rather than longer. A smash you can hit out on a whim is just the old
bug wearing a hat.

THE FALL HOLDS. "the opponent should fall and then lie there for a quick
moment, the player stands up too quick." fall3 goes 0.30 -> 0.62. A corpse
that touches the ground and is instantly upright reads as a stumble.

THE CARD. Ten opponents, six of them NEW bodies off the one-sheet pipeline
(shambler, ball boy, steward, runner, groundskeeper, line judge), with the
club pro's own body and the brute woven in as 7 and 8. It is a ladder of
STAFF: the further you get, the further up the club the thing across the net
used to be. Narrative and difficulty are the same list read twice, and a test
walks all ten — whiff must fall every rung, names must fit the board's 8-char
slot, speed may zigzag by character but never regress.

Steeper per rung (speedPer .08->.11, pacePer .035->.05, flubKeep .82->.76):
"in general this is too easy now". With aimed returns no longer sailing out,
the player stopped handing over free points and the ladder had to earn them
back. The opening got SOFTER at the same time — 0.72 where the old first
opponent was 0.85.

THE UMPIRE WAS MUTED IN THIS MODE and nobody noticed. aside() refused during
any interlude; the match runs on a permanent one. So the single mode that is
literally a tennis match he is sitting over was the one mode he never spoke
in. Same bug shape as sendBoy, same fix. Six dry lines via say -v Daniel: two
on taking a limb, two on your blood, one when there is nearly none left, one
on felling the thing. asideEvery still gates him.

...and sendBoy had the identical bug, so the mop boy now comes out between
games to the FAR court, where one body has been bleeding through four wound
stages. Between games only, never mid-point.

The line judge took two rolls: the first cut to 6 figures in its last row and
_zsheet.py refused it rather than mis-slicing. That refusal is the feature.

59 tests green. Verified in the browser: SHAMBLER on court in its own body.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-12 19:45:31 +02:00
1 parent c1f7268a04
commit 52b38e4a39
200 files changed
+153 -27

No files matched your search

+19 -2
View File
@@ -361,7 +361,10 @@ function matchReady() {
// section about, and the horde's warning is on its own clock for the same reason. The 'over' beat is
// only there to keep the court quiet, and it is set to outlive the fall so it cannot expire into
// setupPoint() mid-collapse and hand the player a serve over a body that is still on the ground.
const FALL = [['fall1', 0.16], ['fall2', 0.18], ['fall3', 0.30], ['rise', 0.34]];
// fall3 is the one that HOLDS: "the opponent should fall and then lie there for a quick moment, the
// player stands up too quick." A corpse that touches the ground and is instantly upright reads as a
// stumble; the beat only lands if it is down long enough to look beaten.
const FALL = [['fall1', 0.14], ['fall2', 0.16], ['fall3', 0.62], ['rise', 0.36]];
export const FALL_LEN = FALL.reduce((s, f) => s + f[1], 0);
function startFall(from) {
@@ -463,13 +466,22 @@ function matchPoint(side) {
if (side === 'O') {
G.hp--; G.hurtT = TUNE.hurtGrace; G.hurtFlash = 0.6;
aww(0.8);
// ...and the umpire notices, drily. Situational, never a pool on a timer: the LOW line only
// exists once the blood is nearly gone, so it lands as a warning rather than as commentary.
aside(G.hp <= 2 ? 'ump_low_blood' : (score.pts[1] % 2 ? 'ump_wound_you2' : 'ump_wound_you'), 0.7);
if (G.hp <= 0) return killPlayer(); // eaten — the run summary, as ever
}
const over = advancePoint(side); // banks the game and resets the points itself
if (over) return side === 'P' ? matchGameWon(zb, true) : matchGameLost();
voice(scoreKey(), 0.4);
if (side === 'P') cheer(0.8, 0.3);
if (fellFrom >= 0) return startFall(fellFrom); // it goes down, and gets up short of a limb
if (fellFrom >= 0) {
// A LIMB IS WORTH A REMARK. Two lines, alternating on the stage taken, so the same sentence
// never lands twice in one game — a repeated aside reads as a stuck HUD, the same reason the
// praise words cycle rather than roll.
aside(fellFrom ? 'ump_wound_opp2' : 'ump_wound_opp', 0.9);
return startFall(fellFrom); // it goes down, and gets up short of a limb
}
matchReady(); // next point, same opponent, both on their marks
}
@@ -483,10 +495,15 @@ function matchGameWon(zb, banked = false) {
G.runGames++; // the ladder metric, same as every mode
G.hp = Math.min(G.hpMax, G.hp + TUNE.matchHeal); // a clean game is how you last
voice('game_player', 0.3); cheer(2.4, 0.6);
aside('ump_felled', 1.5); // ...over the applause, as he would
// GAME is the FLASH and the next opponent's name plate is the CARD. It used to be the other way
// round and matchOpponent's plate landed on the same slot a frame later, so 'GAME.' never drew
// once — and the plate is the better card anyway: it announces the win by replacing the loser.
G.flash = { text: 'GAME.', t: 1.4 };
// ...and the boy comes out to deal with what four wound stages left on the far court. Deliberately
// the FAR side: that is where the body has been standing and bleeding, and it keeps him out of the
// near court where the player is about to serve. Between games only — see sendBoy's match note.
sendBoy(0, 4.5, true);
d.opp++;
// ALTERNATING SERVICE, as the sport has it. The match calls advancePoint directly, which banks
// the game but never touches the server — that flip lives in tennis's own pointTo, which the
+45 -15
View File
@@ -22,10 +22,16 @@ export const TUNE = {
// net-clearance loop ate the whole smash: a flat fast ball can't get over the
// tape AND come down on a target, so it got throttled back to walking pace.
// Heavy topspin lets it leave flat and DIVE, which is what a smash actually is.
smashBoost: 0.42, // extra pace on a FORWARD flick (aim.az < 0), scaled by timing. It does not
// send the ball longer — solveShot still lands it on the same spot — it
// gets there flatter and sooner, which is what a smash is.
aimError: 2.4, // m of scatter at quality 0
// NOT `aimReach` — that name is taken further down by the slingshot's aim-ray LENGTH (22 m), and
// the duplicate key silently won: every aimed shot reached 22x its width until a test caught it.
flickReach: 0.45, // how much of an aimed shot's width a MISTIMED flick gets (1 = all of it).
flickWidth: 3.95, // m off centre a PERFECTLY timed flick reaches. The singles line is 4.115,
// so this is the corner with 17 cm to spare. Raising it past the line does
// not make the flick punchier, it makes a perfect one go out.
flickReach: 0.42, // how much of an aimed shot's width a MISTIMED flick gets (1 = all of it).
// Reach x quality must stay under the sideline once aimError is added on
// top, or the flick goes out — which is exactly the bug this fixed:
// 3.3 * 0.45 + 2.4 = 3.89 against a 4.11 m line. Raise it and the flick
@@ -403,12 +409,16 @@ export const TUNE = {
// the broken recovery (the player never walked back to centre, so every
// wide return looked like opponent strength) — with real footwork restored
// it starts gentler and the ladder still climbs
matchSpeedPer: 0.08, // ...growing this much per opponent felled. Softened once: each rung moves
// STEEPER PER RUNG (2026-08-12): "in general this is too easy now". The flick fix means an aimed
// return can no longer sail out, so the player stopped handing over free points and the ladder had
// to earn them back. The OPENING is deliberately softer than before (the shambler starts at 0.72
// where the old first opponent was 0.85) — the ramp got steeper at both ends, not just harder.
matchSpeedPer: 0.11, // ...growing this much per opponent felled. Softened once: each rung moves
// THREE dials at once (speed, flub, width, pace), and the recorder showed
// opponent two as a wall — he beat one and lost every gap to two by ~0.3 m
matchFlub: 0.08, // the match's own whiff base — the duel's 0.2 gives away a fifth of all
// returns, which across a game is an opponent that beats itself
matchFlubKeep: 0.82, // ...and it keeps only this much per rung — the ladder now that hp is flat
matchFlubKeep: 0.76, // ...and it keeps only this much per rung — the ladder now that hp is flat
matchReach: 1.35, // contact reach multiplier in the MATCH. The recorder showed swings missing by
// centimetres point after point — the assist parks you near the ball, not inside
// a 1.3 m reach. A rally mode lives on contact feeling fair. Match-only
@@ -417,7 +427,7 @@ export const TUNE = {
// is the game's job. Held keys still override
matchOppPace: 0.78, // scale on the opponent's return SPEED for opponent one (~21 m/s instead
// of ~27) — the flight time auto-run needs. THE reachability dial
matchOppPacePer: 0.035,// ...climbing back per rung, capped at full pace (softened with speedPer)
matchOppPacePer: 0.05,// ...climbing back per rung, capped at full pace (softened with speedPer)
matchWideBase: 2.1, // m either side of centre the FIRST opponent's returns may land — the width an
// auto-run player can actually cover. Its scatter painted lines from game one
// ("still a LOT of unreachable balls"); now line-painting is the late ladder
@@ -690,18 +700,38 @@ export const bossArt = ch => ch % 10 === 0 ? BOSS_ART[BOSS_ART.length - 1]
// name it here, and that opponent walks on as it — through all three wound stages.
// short 8 characters, because that is the board's name slot — the match board names
// the thing you are playing instead of calling it THEM
// THE CARD, in order. It is a ladder of STAFF: the further you get, the further up the club's
// hierarchy the thing across the net used to be. That is the narrative, and the difficulty ramp is
// the same list read as numbers — every dial climbs monotonically, so "start easy and get harder"
// is a property of the table rather than something the code has to arrange.
// 1-2 the ones who fetch and sweep. Slow, wild, forgiving.
// 3-4 the ones who keep order. They start covering ground.
// 5-6 the ones who judge. Width arrives: they begin painting lines.
// 7-8 the ones who actually played. The club pro's own body, and the brute behind him.
// 9-10 management, and then the man whose name is on the honours board.
const OPPONENTS = [
{ short: 'KEEPER', line: 'HE MOWED THIS LAWN FOR THIRTY YEARS.', speed: 0.85, pace: 0.9, wide: 0.85, fam: 'br' },
{ short: 'JUDGE', line: 'EVERY BALL OF YOURS IS OUT.', wide: 1.25, flub: 0.8 },
{ short: 'UMPIRE', line: 'HE WILL NOT BE OVERRULED.', speed: 1.1, flub: 0.7 },
{ short: 'COACH', line: 'HE HAS SEEN YOUR BACKHAND.', pace: 1.15, wide: 1.1 },
{ short: 'PHYSIO', line: 'HE IS HERE TO TREAT YOU.', speed: 1.2, pace: 1.1 },
{ short: 'MASCOT', line: 'THE CROWD LOVES HIM.', flub: 1.2, wide: 1.3 },
{ short: 'STRINGER', line: 'HE RESTRUNG THIS RACKET HIMSELF.', pace: 1.2, flub: 0.75 },
{ short: 'DIRECTOR', line: 'HE CAN HAVE YOU REMOVED.', speed: 1.15, wide: 1.2 },
{ short: 'SELLER', line: 'WITH CREAM.', pace: 1.1, speed: 1.1, wide: 1.15 },
{ short: 'CHAMPION', line: 'HE RETURNS EVERYTHING.', pace: 1.25, speed: 1.3, wide: 1.25, flub: 0.5 },
{ short: 'SHAMBLER', name: 'THE SHAMBLER', fam: 'sh',
line: 'HE WAS HERE BEFORE ANY OF THIS.', speed: 0.72, pace: 0.72, wide: 0.75, flub: 1.35 },
{ short: 'BALL BOY', name: 'THE BALL BOY', fam: 'bb',
line: 'HE STILL FETCHES. HE JUST BITES NOW.', speed: 0.82, pace: 0.8, wide: 0.82, flub: 1.2 },
{ short: 'KEEPER', name: 'THE GROUNDSKEEPER', fam: 'gk',
line: 'HE MOWED THIS LAWN FOR THIRTY YEARS.', speed: 0.9, pace: 0.88, wide: 0.9, flub: 1.05 },
{ short: 'STEWARD', name: 'THE STEWARD', fam: 'st',
line: 'HE IS ASKING YOU TO LEAVE.', speed: 0.98, pace: 0.95, wide: 1.0 },
{ short: 'RUNNER', name: 'THE RUNNER', fam: 'ru',
line: 'HE COVERS THE COURT. ALL OF IT.', speed: 1.22, pace: 1.0, wide: 1.05, flub: 0.95 },
{ short: 'JUDGE', name: 'THE LINE JUDGE', fam: 'lj',
line: 'EVERY BALL OF YOURS IS OUT.', speed: 1.05, pace: 1.05, wide: 1.28, flub: 0.85 },
{ short: 'CLUB PRO', name: 'THE CLUB PRO',
line: 'HE GAVE LESSONS ON THIS COURT.', speed: 1.12, pace: 1.15, wide: 1.15, flub: 0.8 },
{ short: 'THE BULK', name: 'THE BULK', fam: 'br',
line: 'NOTHING GETS PAST HIM TWICE.', speed: 1.0, pace: 1.3, wide: 1.2, flub: 0.75 },
{ short: 'DIRECTOR', name: 'THE DIRECTOR',
line: 'HE CAN HAVE YOU REMOVED.', speed: 1.22, pace: 1.22, wide: 1.25, flub: 0.65 },
{ short: 'CHAMPION', name: 'THE CHAMPION',
line: 'HE RETURNS EVERYTHING.', speed: 1.35, pace: 1.3, wide: 1.3, flub: 0.5 },
];
// Who opponent N is. The chapter roster's own order is reused wholesale (bossArt), so the champion
// lands on opponent 10 exactly as he lands on chapter 10 and the two rosters can never drift apart.
export const matchFoe = n => {
@@ -741,7 +771,7 @@ export const BENCH_STAGES = ['HEALTHY', 'ARM OFF', 'HEADLESS'];
// that (15 to 48 away from z_br1, where a good frame scores under 8.5) and refuses them, which is
// the gate working, not the gate being wrong. Re-registering it is one entry here; see the note at
// the top of _master.py for the fix its next attempt needs.
export const ZFAMS = ['r', 'br'];
export const ZFAMS = ['r', 'br', 'sh', 'bb', 'st', 'ru', 'gk', 'lj'];
// THE RACKET FAMILY'S THREE STAGES, as a FRACTION of the pool — never against literal hp, the rule
// this whole file already holds (see woundStage). It lived inline in drawZombie; the FALL BEAT needs
+14 -2
View File
@@ -152,9 +152,15 @@ function doHit(ch, quality, aim) {
// timed flick is the full 3.3 with no scatter left on it at all. Placement is still earned;
// it just stops being a coin toss you are punished for calling.
const reach = ch.side === 'P' ? TUNE.flickReach + (1 - TUNE.flickReach) * quality : 1;
tx = aim.ax * (toFar ? 3.3 : 2.7) * reach + err();
tx = aim.ax * (toFar ? TUNE.flickWidth : 2.7) * reach + err();
const tzBase = aim.az < 0 ? SHOT_DEPTH.deep : aim.az > 0 ? SHOT_DEPTH.short : SHOT_DEPTH.mid;
tz = (toFar ? tzBase : COURT.L - tzBase) + err() * 0.8;
// A FORWARD FLICK IS A SMASH. Up-screen is the far court, so flicking INTO the court asked for
// depth and nothing else — the gesture had a direction with no consequence behind it. It buys
// PACE now, scaled by timing like the width is. Note what it deliberately does not do: solveShot
// still lands the ball on the same tz, so extra speed makes the shot flatter and earlier rather
// than longer. A smash you can hit out on a whim would just be the bug above wearing a hat.
if (ch.side === 'P' && aim.az < 0) s *= 1 + TUNE.smashBoost * quality;
aimedLane = true;
}
tx = Math.max(-5, Math.min(5, tx));
@@ -468,7 +474,13 @@ export function dismissModal() {
// and not situation aware.. they seem random". `aside()` is the single entry point and it enforces
// one shared cooldown (TUNE.asideEvery), so several triggers firing at once still yields one remark.
export function aside(name, delay = 0.2) {
if (G.mode !== 'splatter' || G.modal || G.changeover || G.interlude || G.card) return;
// THE MATCH IS THE EXCEPTION to the interlude bar, exactly as sendBoy is: the match runs on a
// PERMANENT interlude, so this line silently muted the umpire for an entire mode — the one mode
// that is literally a tennis match he is sitting over. The shared asideEvery cooldown still holds,
// which is the rule that keeps him situational rather than chatty ("too much and not situation
// aware.. they seem random"). Cards still block him: a name plate is already the mode talking.
if (G.mode !== 'splatter' || G.modal || G.changeover || G.card) return;
if (G.interlude && !G.match) return;
if (G.asideT > 0) return;
G.asideT = TUNE.asideEvery;
voice(name, delay, 0.95);
+1 -1
View File
@@ -195,7 +195,7 @@ export function proj(x, y, z) {
// Asset cache-buster — bump with main.js?v=N (see the img.src note below). EXPORTED because the
// menu screens hang <img> tags on the same files, and two copies of this number is one copy too
// many: the one that does not get bumped is the one that serves a cached 404.
export const SPRV = 102;
export const SPRV = 103;
// The boot bar in main.js watches this. Nothing here CHANGES when things load — every sprite was
// already requested at import — it just makes the wait visible and holds the menu back until the
// last sheet has landed. See the onerror note below for the other half of "sometimes missing".
+6 -1
View File
@@ -201,7 +201,12 @@ export function setupPoint() {
const mopWorthIt = () => gore.some(d => d.a >= 0.15 && d.z >= COURT.netZ - 2);
export function sendBoy(tx = ball.x, tz = ball.z, mop = G.mode === 'splatter') {
if (G.interlude) return; // the sudden-death duel is clean tennis: nobody mops through it
// The sudden-death duel is clean tennis: nobody mops through it. THE MATCH IS THE EXCEPTION — it
// runs on a PERMANENT interlude, so this guard silently barred the boy from an entire mode, and a
// match is where the gore actually accumulates: one body bleeds through four wound stages in the
// same spot, game after game. He is let out between games only (matchGameWon calls him), never
// mid-point, which is the same "guaranteed calm court" rule the modals live under.
if (G.interlude && !G.match) return;
if (G.drill) return; // ...and nobody walks onto a drill. He is one more body crossing the
// lanes you are reading colours down, and unlike everything else out
// there he is not part of the puzzle OR the threat — pure noise.