Files
splatter-tennis/src/zombies.js
T
wuwiandClaude Opus 5.5 75b450f105 Story phase 1: the spine — acts, cups, chapters, checkpoints
The front door now continues the story. An act is its league cup, whose
final is the boss's own body (acts II and IV play their band out of
ladder order for that), then a ten-game gore chapter on the act's court.
The chapter has no duel at all, takes the act's weapon stock into the
rack, meets a mid-boss after game 5 (the checkpoint: restock and full
blood) and the act boss after game 10, then the act card. Death is not
permadeath: it counts the attempt, banks nothing on the boards, and
TRY AGAIN restarts from the checkpoint the profile holds. Barrels hold
what the act has earned. The chapter plays the arcade's games
base+1..base+10 so every director curve keeps its slope.

Profiles from before the story start it from the top (league, cups and
champion reset; level kept). The server mirror stores the act and
checkpoint and only ever moves them forward.

Plain cards stand in for the screens (phase 3) and the transformation
(phase 2). Six new tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:10:09 +02:00

1490 lines
99 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The splatter phase: shamblers, gore decals, particles. The ball is the weapon;
// tennis scoring keeps running via gameplay.splatPoint. Zombies advance toward
// your baseline — one crossing it is a point for THEM.
// (zombies <-> gameplay is a runtime-only import cycle, same pattern as input.)
import { TUNE, UI, COURT, CONTACT, BOSS_ART, bossArt, WEAPONS, BARREL_WEAPONS, TIN_LEAGUE, woundStage, zombieH, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R, storyPool } from './config.js';
import { G, P, O, ball, score, sendBoy, setupPoint } from './state.js';
import { splat, moan, pock, thud, zap, voice, boom, swish, shriek, aww, sfx , whoosh, flame, squeak, crackle, whump, gunshot, stomp, growl } from './audio.js';
import { haptic } from './haptics.js';
import { drillKill, drillCaught } from './drill.js';
import { splatPoint, cheer, spentBall, aside, grantWeapon, endBurst, sliceCarom } from './gameplay.js';
import { resolveInterlude, endBossFight, storyDeath } from './beats.js'; // runtime-only cycle, like the gameplay one
import { tally, multiKill, endRun, MODE_MATCH } from './stats.js';
import { award, bonus, prof, recordCup } from './profile.js';
export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'}
export const gore = []; // lawn decals {x, z, r, a} — the flat ground layer
export const piles = []; // 3D-ish gore mounds {x, z, size 1..4, seed} — grow where kills cluster
export const parts = []; // splatter particles {x, y, z, vx, vy, vz, t, dark}
export const pellets = []; // machine-gun rounds during a horde {x, y, z, vx, vz, t}
export const extras = []; // triple-serve side balls {x, y, z, vx, vy, vz, pow, t} — ballistic clones
// INCOMING hostile balls: the ball machine's shots and the chapter boss's volleys. Deliberately a
// separate array from `ball` and `extras` rather than an extension of either — the main ball is the
// one you serve and there can only be one of it, and extras are yours and kill zombies. These are
// theirs and they hurt YOU. Swat one with a swing and it becomes an `extras` entry flying back
// up-court, which is how a parry turns into a real weapon for free.
export const shots = []; // {x, y, z, vx, vy, vz, g, t}
// world-anchored reaction pops: DOUBLE KILL rises off the corpse that earned it, PERFECT lands on
// the impact — the answer to "what did I just do" appears where you are already looking, not in
// the announcement band. Render draws them, gameplay's update ticks them, killed in resetZombies.
export const pops = []; // {x, y, z, text, col, t, T}
export function popText(x, y, z, text, col, still = false) {
pops.push({ x, y, z, text, col, still, t: 0.85, T: 0.85 }); // still: does not rise (the rally's praise, under the player)
if (pops.length > 10) pops.shift();
}
let spawnT = 1.5, moanT = 3, crackleT = 0, whumpT = 0, bossHurtT = 0; // the last three throttle the fire and boss sounds
// Every body, decal and projectile on the court, plus this module's own timers. Called by
// gameplay.restartRun — a restart is in-place now, not a page reload.
export function resetZombies() {
for (const a of [zombies, gore, piles, parts, pellets, extras, shots, pops]) a.length = 0;
spawnT = 1.5; moanT = 3; crackleT = whumpT = bossHurtT = 0; mgTick = 0; lastBarrel = null;
}
export function spawnZombie(x, z, variant) {
// Keyed on GAMES FINISHED, not on splatT: the old `1 + min(1.5, splatT / 70)` maxed out after
// 105 seconds of splatter, so every shambler from roughly game 3 onward walked at the same top
// speed for the rest of a 50-game run. Games are what the player experiences as progress.
// test mode pins the shamble at a fixed MEDIUM speed instead of riding the per-game ramp
const ramp = 1 + Math.min(TUNE.zSpeedMax, G.runGames * TUNE.zSpeedPerGame);
const base = G.testMode ? TUNE.testZSpeed : TUNE.zSpeed * ramp;
const zb = { x, z, v: base * (0.8 + Math.random() * 0.4), walkT: Math.random() * 9,
homeOff: (Math.random() * 2 - 1) * 0.8, variant }; // personal lane offset so they don't stack into a column
if (variant === 'f') { zb.v = Math.min(TUNE.runnerVMax, zb.v * TUNE.runnerSpeed); shriek(); } // shriek = the fairness contract; capped so late runners stay reactable
if (variant === 'a' || variant === 'b') zb.hp = TUNE.shamblerHp; // classic shamblers take a wound before they drop — only the runner is one-shot glass
if (variant === 'g') { zb.v *= 0.55; zb.hp = TUNE.bruteHp; } // slow, but soaks hits
if (variant === 'd') { zb.v *= 0.8; zb.hp = TUNE.stewardHp; } // the steward: a head taller and several hits tougher than the crowd, nothing more exotic than that
if (variant === 'e') zb.hp = TUNE.ballboyHp; // the ball boy: shambler-sized, three hits deep
if (variant === 'h') { zb.v *= 0.75; zb.hp = TUNE.bouncerHp; } // the bouncer: between the steward and the brute
if (variant === 'k') { zb.v *= 0.45; zb.hp = TUNE.rollerHp; } // the roller man: slower and heavier than the brute
// Every body with hp remembers its FULL hp. The wound skins used to compare against the variant's
// TUNE constant, which meant every new body needed its own branch to look hurt; one field here and
// the rule is "hp below full = show the wound", for anything that has a wound skin.
if (zb.hp) zb.hpMax = zb.hp;
if (variant === 's') zb.shieldT = 0; // chair up; > 0 = seconds of stagger left
if ('fgsdehk'.includes(variant) && !G.seen['v' + variant]) { G.seen['v' + variant] = true; aside('ump_dress_code', 0.8); }
if (variant === 'o') { // the weapon barrel: a rolling pink target with a weapon inside it
zb.v = 0; zb.hp = zb.hpMax = TUNE.barrelHp;
zb.wid = pickBarrelWeapon();
zb.dir = Math.random() < 0.5 ? -1 : 1; zb.roll = 0;
}
if (variant === 'm') { // the rogue ball machine: furniture, not a body. It never SHAMBLES...
zb.v = 0; zb.hp = zb.hpMax = TUNE.machHp; zb.fireT = TUNE.machCycle; zb.windT = 0;
zb.repoT = TUNE.machRepo * (0.4 + Math.random()); // ...but it wheels itself somewhere else now and then
}
zombies.push(zb);
}
let lastBarrel = null;
// WHICH weapon is in the barrel. A flat 1-in-4 gave Oliver the laser "almost every time" and the
// beacon never — four samples is far too few for uniform to feel uniform. Weighted, gated on the
// run so the rocket is genuinely a late-game find, and it never hands you the same thing twice in
// a row, which is the part a player actually notices.
function pickBarrelWeapon() {
// THE STORY: a barrel holds what the act has earned (config.storyPool) — the act decides the rack,
// so a barrel in act I is a bomb, a triple or fire, never the laser the story hands out in act III
if (G.story) {
const own = storyPool(G.story.act).filter(id => id !== lastBarrel);
const id = own.length ? own[Math.floor(Math.random() * own.length)] : lastBarrel || 'bomb';
lastBarrel = id;
return id;
}
const pool = [];
for (const [id, b] of Object.entries(BARREL_WEAPONS)) {
if (G.runGames < b.from) continue;
// THE WEAPON LICENCE (the leagues): the league licenses, `from` paces — a barrel must not
// hand a league-1 player the laser his duels rightly refuse (config.TIN_LEAGUE)
if (prof.lg < (TIN_LEAGUE[id] || 0)) continue;
if (id === lastBarrel) continue; // never twice running...
for (let k = 0; k < b.weight; k++) pool.push(id);
}
// ...unless it is the only thing unlocked, in which case repeating beats handing out nothing
if (!pool.length) return lastBarrel || 'beacon';
const id = pool[Math.floor(Math.random() * pool.length)];
lastBarrel = id;
return id;
}
// A WEAPON BARREL, rolled across the far court. Shoot it and you get what is inside, armed on the
// spot. It is the only source of the four weapons that are not on the duel ladder, and it is pure
// upside — it never attacks, never scores, and rolls off on its own if you ignore it.
export function spawnBarrel() {
spawnZombie((Math.random() * 2 - 1) * 3.2, 4.5 + Math.random() * 3, 'o');
// a CARD, not a modal: it lands at a game-over beat that can have a standing wave on court, and
// a world-stopping popup there is exactly the "popups get me killed" report. Same for the
// machine's and the racket zombie's introductions below.
if (!G.seen.barrel) {
G.seen.barrel = true;
G.card = { title: 'A GLOWING BARREL', sub1: 'A WEAPON IS INSIDE — SHOOT IT',
color: UI.pink, t: 2.6 };
}
}
// the rogue ball machine, wheeled out onto the far court. It never moves and never scores; it just
// shoots at you until you deal with it.
export function spawnMachine() {
spawnZombie((Math.random() * 2 - 1) * 3.6, 2.2 + Math.random() * 3, 'm');
aside('ump_machine', 0.4); // benched with the rest of Daniel's personality — see aside()
if (!G.seen.mach) {
G.seen.mach = true;
// the card NAMES the thing it explains — an instruction without a subject read as noise
// (2026-08-29 copy audit)
G.card = { title: 'A BALL MACHINE', sub1: 'SWING ITS SHOTS BACK — OR SWISH THEM AWAY',
color: UI.warm, t: 2.8 };
}
}
// ---- the chapter boss. One every TUNE.bossEvery games, bigger and tougher each time. It advances
// slowly and volleys at you, so it threatens BOTH channels: its shots chip your hp, and letting it
// reach you is the ordinary instant death. Reuses the whole existing damage model — it is just a
// body with a lot of hp (see the wound() invariant in PROJECT.md), so no weapon needs special-casing.
export function spawnChapterBoss(chapter, design = null) {
// which of the boss designs this chapter is (every tenth = THE CHAMPION) — or, in THE STORY, the
// act's own design at the act's strength (config.storyBossArt / storyBossChapter)
const art = design || bossArt(chapter);
// Every behaviour field on the design is OPTIONAL and falls back to the shared TUNE number, so a
// design row that carries nothing but art behaves exactly the way a boss did before there were any.
const hp = Math.round((TUNE.bossHp + (chapter - 1) * TUNE.bossHpPerChapter) * (art.hpMul ?? 1));
zombies.push({ x: 0, z: -1.2, v: TUNE.bossSpeed * (art.speedMul ?? 1), walkT: 0, homeOff: 0, variant: 'B',
chapter, hp, hpMax: hp, scale: TUNE.bossScale + (chapter - 1) * TUNE.bossScalePer,
fireT: art.cycle ?? TUNE.bossCycle, windT: 0, salvo: 0, salvoT: 0,
dist: 0, dir: Math.random() < 0.5 ? -1 : 1, moveT: 0, // dist drives the stride: see drawZombie
art });
}
// ---- BOSS MOVEMENT. Before this the boss had no AI at all — it matched no branch in the variant
// chain below and fell through to the crowd's aimless ±0.15 m/s wobble, so "the boss moves" was a
// comment and nothing else. Each design owns a style (BOSS_ART.move) and they differ in the axis
// they threaten on: pace/bounce make you lead a moving target, mirror takes your lane away, charge
// trades its ranged game for closing the distance. Returns nothing; it moves zb.x/zb.z directly.
//
// Two rules hold for all of them:
// - IT PLANTS ITS FEET TO THROW. While winding up or mid-volley it does not move, which is the same
// fairness contract as the wind-up glow: the telegraph has to be worth reading.
// - IT STAYS IN THE BAND (bossZMin..bossZMax) unless it is charging. Uncapped, a boss ended the
// fight by simply arriving on top of you after about a minute — and every extra hp made that
// outcome MORE likely, not less. Only 'charge' is allowed through, and that is its whole threat.
// A NOTE THAT DECIDED THREE OF THESE FIVE: in splatter the player is rooted in `pretoss` for nearly
// the whole of a boss fight (he only walks during `rally` — a settled decision). So any style whose
// motion is "close on the player's x" arrives, stops, and is static for the rest of the fight, which
// is the exact complaint this work exists to fix. Every style below therefore moves under its OWN
// power and only BIASES toward the player. Measured: each of the six covers 5-14 m a minute.
// WHICH PHASE a boss is in, or -1 for a design that has none. Health drives it, so it is derived
// rather than stored and cannot desync from the hp it is meant to describe.
const bossPhase = zb => {
const n = zb.art?.phases?.length;
return n ? Math.min(n - 1, Math.floor((1 - zb.hp / zb.hpMax) * n)) : -1;
};
// The design a boss is FIGHTING as, this instant: its own row, with the current phase's fields laid
// over it. Everything that reads a boss's behaviour goes through here, so a phase stays a data row
// and never becomes a second code path. (There is one boss on court; the object per call is free.)
export const bossPlan = zb => {
const A = zb.art || {}, i = bossPhase(zb);
return i < 0 ? A : { ...A, ...A.phases[i] };
};
function bossAI(zb, dt) {
if (zb.windT > 0 || zb.salvo > 0) return; // planted: throwing is not a moving action
const x0 = zb.x, z0 = zb.z, A = bossPlan(zb), style = A.move || 'advance';
const strafe = TUNE.bossStrafe * (A.paceMul ?? 1);
if (style === 'charge') {
zb.moveT -= dt;
const v = TUNE.bossChargeV * (A.speedMul ?? 1);
if (zb.charging) {
// IT COMMITTED TO A LANE AND IT IS RUNNING DOWN THAT LANE, whatever you do next. Tracking your
// x for the whole rush made the sidestep meaningless — it just followed you across and ran you
// over ("they overrun you without a chance"), and stopping the rush ON you left it standing
// there afterwards, where the 0.5 s dodge cooldown cannot outrun a 0.28 s i-frame. So: the
// lane is locked when the rush begins, you get the entire run-up to leave it, and the rush
// ends on a STRIKE LINE just in front of you, on the same frame the rush timer expires, so it
// turns and withdraws the instant it arrives. A rush that misses is over.
zb.x += Math.sign(zb.lane - zb.x) * Math.min(Math.abs(zb.lane - zb.x), TUNE.bossStrafe * 2 * dt);
zb.z += zb.chargeV * dt;
if (zb.moveT <= 0) { zb.charging = false; zb.moveT = TUNE.bossChargeRest; } // spent: it turns and walks back
} else {
// AND IT FALLS BACK. A rush that ended where it landed left the boss parked on top of you for
// the rest of the fight: dodge or die every two seconds, with no window to hit it and nothing
// but a hitscan laser fast enough to use what window there was — Oliver, "they come too close,
// they seem unbeatable". So the retreat OUTRUNS the rush (retreat > 1) and stops at the front
// of the band rather than crawling a fraction of the way back. A charge is a threat that
// PASSES: you dodge it, and the ground it gives up again is your turn to hit it.
const home = TUNE.bossZMax, sp = v * (A.retreat ?? 1.3) * dt; // toward its LINE, from either side:
zb.z += Math.sign(home - zb.z) * Math.min(Math.abs(home - zb.z), sp); // backward off a rush, forward on the way in
// THE RUSH IS TIMED, NOT PACED, and it always begins FROM THE LINE — never from wherever a
// half-finished retreat happened to leave it, which is how a rush could start on your toes
// with no run-up to read. It covers the whole distance in bossChargeT, so the approach looks
// the same every time whatever the design's speed.
if (zb.moveT <= 0 && Math.abs(zb.z - home) < 0.1) {
zb.charging = true; zb.moveT = TUNE.bossChargeT;
zb.lane = P.x; // the lane, locked. You have the run-up to leave it.
zb.chargeV = (P.z - TUNE.bossStrike - zb.z) / TUNE.bossChargeT;
}
}
} else if (style === 'shadow') {
// paces — but the ground it paces over is centred on YOU, so it never stops moving and it takes
// your lane away the moment you take one
const c = Math.max(-TUNE.bossPaceX, Math.min(TUNE.bossPaceX, P.x));
zb.x += zb.dir * strafe * dt;
if (zb.x > c + TUNE.bossShadowX) zb.dir = -1; else if (zb.x < c - TUNE.bossShadowX) zb.dir = 1;
zb.z += zb.v * dt;
} else if (style === 'bounce') {
// hops, not a slide: it covers bossHopX per hop and is nearly still between them, so it reads as
// a costume with someone inside it rather than a sprite being lerped across the court. It flips
// every hop and only steers when it has drifted a long way off your lane.
zb.moveT -= dt;
if (zb.moveT <= 0) {
zb.moveT = TUNE.bossHopT;
// keep going the SAME way until it runs out of court or has overshot your lane, then turn.
// Flipping every hop kept it oscillating inside one metre — motion with no travel in it.
if (Math.abs(zb.x) > TUNE.bossPaceX) zb.dir = -Math.sign(zb.x);
else if (Math.abs(P.x - zb.x) > 2.5) zb.dir = Math.sign(P.x - zb.x) || zb.dir;
}
const k = Math.max(0, Math.sin(Math.PI * (1 - zb.moveT / TUNE.bossHopT))); // fast at mid-hop
zb.x += zb.dir * (TUNE.bossHopX / TUNE.bossHopT) * k * dt;
zb.z += zb.v * dt;
} else if (style === 'surge') {
// rolls up the whole band and back down it, over and over, drifting slowly into your lane. Its
// DEPTH is the threat, not its x — the one that simply keeps coming, and keeps coming back.
zb.z += zb.dir * zb.v * 3 * dt;
if (zb.z > TUNE.bossZMax) zb.dir = -1; else if (zb.z < TUNE.bossZMin) zb.dir = 1;
const dx = P.x - zb.x;
zb.x += Math.sign(dx) * Math.min(Math.abs(dx), strafe * 0.3 * dt);
} else if (style === 'pace') {
zb.x += zb.dir * strafe * dt;
if (Math.abs(zb.x) > TUNE.bossPaceX) { zb.dir = -Math.sign(zb.x); zb.x = Math.sign(zb.x) * TUNE.bossPaceX; }
zb.z += zb.v * dt;
} else { // 'advance': the old crawl, with a slow drift.
zb.x += Math.sin(zb.walkT * 0.6) * 0.4 * dt; // Unused by any design — it is what a row with
zb.z += zb.v * dt; // no `move` field falls back to.
}
if (style !== 'charge') zb.z = Math.max(TUNE.bossZMin, Math.min(TUNE.bossZMax, zb.z));
zb.x = Math.max(-TUNE.bossPaceX - 0.6, Math.min(TUNE.bossPaceX + 0.6, zb.x)); // never off the sides
// The stride steps off GROUND COVERED (the brute's rule). A strafing boss read off zb.z alone
// would slide sideways with both feet frozen — the exact defect the distance rule exists to stop.
const d0 = zb.dist || 0;
zb.dist = d0 + Math.hypot(zb.x - x0, zb.z - z0);
if (Math.floor(zb.dist / TUNE.bruteStride) !== Math.floor(d0 / TUNE.bruteStride)) stomp(0.35, bodySize(zb)); // one per drawn stride
}
// HE CALLS SECURITY. A design with `adds` puts bodies on the court while you fight it — the one
// thing a boss fight could not do before, since startBossFight clears the stage and normal spawning
// is off for the duration. `cap` counts the boss itself, so the court cannot silt up while a slow
// player works on a big health bar.
function bossAdds(zb, dt) {
const A = bossPlan(zb);
if (!A.adds || (zb.addT = (zb.addT ?? A.adds.every) - dt) > 0) return;
zb.addT = A.adds.every;
if (zombies.length < (A.adds.cap ?? 5)) spawnZombie((Math.random() * 2 - 1) * 5, -1.5 - Math.random(), A.adds.variant);
}
// ---- the player's second health channel. Only these two functions touch it.
export function hurtPlayer(n) {
// G.hurtT is the grace window: a volley must not delete you at once. G.dodgeI is the SIDESTEP —
// it beats this channel and the eat test below, which is the only reason to spend a dodge.
if (G.mode !== 'splatter' || G.hurtT > 0 || G.dodgeI > 0 || G.mode === 'eaten') return;
G.hp -= n;
G.hurtT = TUNE.hurtGrace * bonus().grace; G.hurtFlash = 0.3; // the grace rides the level (the leagues)
haptic('heavy'); // taking one is rare (hurtGrace gates it) and is the loudest thing the hand gets
G.shake = { t: 0.22, mag: 4 };
splat(0.7); aww(0.5);
for (let k = 0; k < 14; k++) parts.push({ // he takes it in the chest
x: P.x + (Math.random() * 2 - 1) * 0.2, y: 1.2, z: P.z,
vx: (Math.random() * 2 - 1) * 2.2, vy: 0.8 + Math.random() * 2, vz: (Math.random() * 2 - 1) * 2.2,
t: 0.35 + Math.random() * 0.35, dark: Math.random() < 0.5 });
if (G.hp <= 0) killPlayer();
else aside('ump_quiet_bleeding', 0.3); // benched with the rest of Daniel's personality — see aside()
}
// Death, from EITHER channel: a shambler reaching you, or your hp running out. Extracted so the two
// causes cannot drift apart — the run summary, the best-run write and the death animation are all
// here exactly once.
export function killPlayer() {
if (G.mode === 'eaten') return;
// In the drill, being caught costs the POINT and not the run — a deliberate contract change,
// see drillCaught. Permadeath stays where it was settled: the gore mode.
if (G.drill) return drillCaught();
gore.push({ x: P.x, z: P.z, r: 0.9, a: 0.9 }); if (gore.length > 40) gore.shift();
for (let k = 0; k < 30; k++) parts.push({
x: P.x, y: 1.1, z: P.z,
vx: (Math.random() * 2 - 1) * 4.5, vy: 1 + Math.random() * 4, vz: (Math.random() * 2 - 1) * 4.5,
t: 0.5 + Math.random() * 0.5 });
splat(1);
voice('game_set_massacre', 0.8); aww(1); // the run is over — the crowd groans
// the death animation runs off G.eatenT: thrash here, burst at EATEN_BURST (playerGore), summary after
haptic('dead'); // the notification pattern, once a run — the only place it is ever used
G.eatenT = 0; G.eatenAt = { x: P.x, z: P.z };
G.mode = 'eaten'; ball.active = false; G.flash = null; G.modal = null; // zombies keep shambling for ambience
G.horde = null; pellets.length = 0; extras.length = 0; shots.length = 0; G.mgT = 0; G.gunId = null; // everything stops
// The run is over — bank it. endRun writes the record AND hands back the best run before this
// one, which is what the summary compares against. It replaced a hand-rolled localStorage.
// splatterBest read/write here: the best is now derived from the record rather than stored twice
// and left to drift. Timing is safe — restartRun() only fires on the tap AFTER death, so
// runGames/sets/splatT are all still intact at this point.
// THE MATCH MEASURES SOMETHING ELSE. Blood-only scoring (model A) means a lost game costs nothing
// but the blood already spent, so "games survived" is not the achievement — OPPONENTS FELLED is.
// It goes through the SAME endRun with the mode tagged and its own two headline numbers in the
// record's two headline slots (see the shape note there), so the best is derived from the record
// rather than stored a second time in its own key and left to drift.
// THE STORY HAS NO PERMADEATH: the death costs the attempt, not the story (prof.story keeps the
// checkpoint), and it banks nothing on the boards — the story is not a high-score run, and its
// chapter games are numbered on the arcade's scale (STORY.base), which would post act V's game
// 85 as a gore best. The summary reads G.story and offers the retry.
if (G.story) { storyDeath(); return; }
const m = G.match;
// the cup's opp is a DRAWN RUNG, not a counter — bodies felled there is completed rounds. The
// arcade counts kills on d.felled now (THE LEAGUES: opp starts at the band's bottom rung and
// clamps at its top, so `opp - 1` stopped meaning anything); the fallback keeps old shapes alive.
const felled = m ? (m.cup ? m.cup.round : (m.felled ?? Math.max(0, m.opp - 1)))
: 0, gamesPlayed = score.games[0] + score.games[1];
const b = m ? endRun(felled, gamesPlayed, G.splatT, true, MODE_MATCH)
: endRun(G.runGames, G.sets[0], G.splatT);
G.best = b; G.newBest = b.newBest; // b is {games, sets, newBest}; render reads games/sets
if (m) { m.felled = felled; m.games = gamesPlayed; m.best = b.games; } // ...the death screen's words
// THE CUP RECORD (THE SEASON): an attempt that ends here is still an attempt. The round you
// were IN is the round you reached, and the rung holding the racket is the one the training
// court will offer you next. Training reps are not attempts and never write it.
if (m && m.cup && !m.train) recordCup(G.league, m.cup.round + 1, m.cup.rung || m.opp);
}
// One hostile ball, lobbed at where the player is standing. `spread` fans a volley sideways.
// It is a real ARC (solved like a serve, not fired flat) so it is readable coming in and so the
// swing window has something to meet — a flat bolt at head height is not a tennis shot.
function fireAtPlayer(x, z, spread = 0, speed = TUNE.machSpeed) {
const y0 = 1.1;
const tx = P.x + spread, tz = P.z;
const d = Math.hypot(tx - x, tz - z), Tf = Math.max(0.35, d / speed);
const g = TUNE.gravity;
shots.push({ x, y: y0, z,
vx: (tx - x) / Tf, vz: (tz - z) / Tf,
vy: (0.9 - y0 + 0.5 * g * Tf * Tf) / Tf, // land at racket height, not on the grass
g, t: Tf + 1.6 });
pock(0.55, 190); // the thunk of the throw
}
// Which shambler steps out next. Unlocks are keyed on GAMES, not on sets won: a set is six games,
// so the old top gate (`tier >= 2`) opened around game 12 and after that the mix was frozen forever
// at 15% shield / 15% brute / 12% runner — a third of why a 50-game run stopped changing.
// Now the advanced SHARE keeps climbing after everything is unlocked, which is what makes game 40
// feel different from game 20 rather than merely faster.
function pickVariant() {
const g = G.testMode ? TUNE.testLevel : G.runGames; // test mode unlocks the whole roster
const pool = [];
if (g >= 2 && !zombies.some(z => z.variant === 'f')) pool.push('f'); // at most one runner alive
if (g >= 6) pool.push('g');
if (g >= 12) pool.push('s');
// The steward is the mildest of the pool on purpose — bigger and one hit tougher, no special rule
// to learn — so he can arrive EARLY and thicken the ordinary crowd, which the shambler mix alone
// never did. He is also the only advanced body that shows up in a horde (see spawnHordeZombie).
if (g >= 4) pool.push('d');
// The late roster, spread WIDE on purpose. They were at 3/8/15 and Oliver had met the whole cast
// by about game 10 — "let's introduce them more gradually". One new body roughly every seven
// games now, and the ORIGINAL four keep their gates exactly (the curve through ~game 12 was
// already tuned; see the calibration rule in PROJECT.md).
if (g >= 5) pool.push('e');
if (g >= 12) pool.push('h');
if (g >= 20) pool.push('k');
const nasty = Math.min(TUNE.nastyMax, g * TUNE.nastyPerGame);
if (pool.length && Math.random() < nasty) return pool[Math.floor(Math.random() * pool.length)];
return Math.random() < 0.5 ? 'a' : 'b';
}
// How big a target a body is. Bosses are deliberately a little more generous than a shambler —
// part of "the racket zombie doesn't seem harmable" was simply missing him.
// A BOSS BODY: takes 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".
const isBoss = zb => zb.variant === 'r' || zb.variant === 'B';
const hitR = zb => zb.variant === 'g' ? 0.75 : zb.variant === 'd' ? 0.64 : zb.variant === 'r' ? 0.7
: zb.variant === 'o' ? 0.7 // the barrel: a wide low drum, generous on purpose
: zb.variant === 'k' ? 0.88 : zb.variant === 'h' ? 0.68 // the roller man is wider than the brute
: zb.variant === 'B' ? 0.55 * (zb.scale || 1) * 1.4 // a chapter boss is a barn door, as it should be
: zb.variant === 'm' ? 0.8 // the machine is a wide low box
: 0.55;
// How big it sounds. 1 = a shambler, and every impact sound scales its pitch and length off this,
// so a ball landing on a brute is audibly a bigger event than one landing on a runner.
const bodySize = zb => zombieH(zb) / 1.8;
// THE LASER. Not a projectile: the serve gesture you already know, and instead of a ball you get a
// widening plane of light down the lane you aimed at, killing everything standing inside the wedge
// at once. Oliver asked for exactly this after a tap-per-round version read as broken — "the normal
// ball logic, but the band is blue and gets way wider at the end, like a blue plane cutting through".
// It goes through wound()/killZombie like everything else, so it cannot no-op on a boss.
export function fireBeam(dirX, dirZ) {
const d = Math.hypot(dirX, dirZ) || 1, ux = dirX / d, uz = dirZ / d;
// out of the PLAYER at chest height — same point the cone is measured from, so what you see and
// what it kills are one geometry
CAUSE = 'laser';
G.beam = { x: P.x, y: TUNE.beamY + TUNE.beamOriginLift, z: P.z, ux, uz, t: TUNE.beamT };
// 0.22, was 0.55: zap() sums FOUR voices (crack, two detuned saws, a sub) straight into the
// destination, so its `vol` is not one voice's level — it was coming out several times louder
// than anything else in the game. Oliver: "laser sound is way too loud."
zap(0.22); G.shake = { t: 0.18, mag: 3 }; G.boomFlash = 0.08;
for (let i = zombies.length - 1; i >= 0; i--) {
const zb = zombies[i];
if (!zb) continue;
const rx = zb.x - P.x, rz = zb.z - P.z;
const along = rx * ux + rz * uz; // how far down the lane it stands
if (along < 0 || along > TUNE.beamLen) continue; // behind you, or past the end of the beam
const off = Math.abs(rx * uz - rz * ux); // lateral offset from the lane
// the kill follows whichever LOOK is on, or the A/B would be a lie: a narrow band that deletes a
// cone-wide crowd tells you nothing about how the narrow band plays
const halfW = TUNE.beamBand >= 0.5 ? TUNE.beamBandW * 2
: TUNE.beamW0 + (TUNE.beamW1 - TUNE.beamW0) * (along / TUNE.beamLen);
if (off > halfW + hitR(zb)) continue;
// THE BEAM DELETES ORDINARY BODIES. hp does not enter into it: a plane of light that left a
// roller man standing was the thing that made it feel like a worse ball rather than a weapon.
// A BOSS is the exception and takes a heavy wound instead — "they also must suffer severely".
if (isBoss(zb)) { if (wound(zb, TUNE.dmgBeam)) killZombie(i, true); }
else killZombie(i, !!G.horde);
}
}
// core kill: remove the zombie, spawn its gore/pile/particles, score. No ball coupling —
// shared by ball hits, machine-gun pellets, melee swipes, and extra (multi-serve) balls.
// how absurdly a variant dies: classics splash, the advanced roster DETONATES
const GORE_MUL = { a: 1, b: 1, e: 1, f: 1.8, s: 2.5, h: 2.5, g: 4, k: 5, r: 3, m: 1.5, o: 0, B: 6 }; // a chapter boss comes apart like nothing else on the court; a barrel is not meat at all
// WHAT IS KILLING THINGS RIGHT NOW. killZombie takes an index, not a cause, and eleven call sites
// reach it — threading a weapon id through all of them (and through wound(), and through
// splatter()) to feed one number on a menu is not worth the churn. Every damage loop in this file
// instead stamps this before it can kill, and killZombie reads it. Safe because the whole thing is
// one synchronous frame: nothing interleaves between the stamp and the kill it causes.
let CAUSE = 'ball';
// A barrel bursts and hands you what was inside. Pink debris rather than gore — it is a container,
// not a body. It does NOT equip itself: shooting a barrel mid-plan used to yank the gun into your
// hand and the next serve fired the wrong thing — the current weapon stays live and the new slot
// pulses pink instead (render.drawRack reads G.rackGlow). The announcement is a CARD, never a
// modal: a barrel pops mid-combat, and a popup that stops the world there costs lives, not reads.
function popBarrel(zb) {
boom(0.7); G.shake = { t: 0.22, mag: 4 }; G.boomFlash = 0.08;
for (let k = 0; k < 30; k++) parts.push({
x: zb.x, y: 0.7, z: zb.z,
vx: (Math.random() * 2 - 1) * 4, vy: 1.2 + Math.random() * 3.5, vz: (Math.random() * 2 - 1) * 4,
t: 0.35 + Math.random() * 0.5, pink: true });
const keep = G.weapon;
const got = grantWeapon(zb.wid);
G.weapon = keep;
G.rackGlow = { slot: got, t: 2.2 };
const w = WEAPONS[zb.wid];
G.card = { title: w.label, sub1: (w.desc && w.desc[0]) || '', sub2: 'TAP ITS RACK SLOT TO ARM IT',
icon: w.icon, color: UI.pink, t: TUNE.tinCardT };
}
// dark smoke: one puff, drifting up and spreading. Not gravity-bound like gore (see the parts loop).
function smokePuff(x, y, z, spread = 0.5) {
parts.push({ x: x + (Math.random() * 2 - 1) * spread, y, z: z + (Math.random() * 2 - 1) * spread * 0.5,
vx: (Math.random() * 2 - 1) * 0.35, vy: 0.5 + Math.random() * 0.5, vz: (Math.random() * 2 - 1) * 0.35,
t: 0.9 + Math.random() * 0.9, smoke: true });
}
// The machine is the one thing on court that is not made of meat, and it used to burst like a body:
// blood decal, gore mound, the wet splat, a ball boy sent out to mop it. It comes apart as MACHINERY
// now — a flash, sparks, torn metal, and a column of dark smoke over a scorch mark.
function wreckMachine(zb) {
boom(); G.shake = { t: 0.3, mag: 5 }; G.boomFlash = 0.09;
gore.push({ x: zb.x, z: zb.z, r: 1.1, a: 0.95, scorch: true }); // burnt clay, not blood
if (gore.length > 40) gore.shift();
for (let k = 0; k < 26; k++) parts.push({ // sparks and burning debris
x: zb.x, y: 0.8, z: zb.z,
vx: (Math.random() * 2 - 1) * 4, vy: 1.5 + Math.random() * 4, vz: (Math.random() * 2 - 1) * 4,
t: 0.3 + Math.random() * 0.5, fire: true });
for (let k = 0; k < 26; k++) smokePuff(zb.x, 0.5 + Math.random() * 1.6, zb.z, 0.7); // a column, not a puff
}
// A chapter boss does not just die. It is the thing the whole fight was about, so it comes apart
// in its own beat: the world drops into slow motion, the screen kicks, a ring of blood blows out
// through the crowd's roar, and the card that announces it waits until the pieces have landed.
function bossDeath(zb) {
G.slowT = TUNE.bossSlowT; // real seconds; main.js scales dt while it runs
// ...and the camera leans in on it: the transition's punch-in, reused at a gentler magnification,
// riding the slow-mo so the longest fight in the game ends on a camera moment. T is GAME time
// (update ticks zoom with the scaled dt), so the peak lands mid-slow-mo and eases out after.
G.zoom = { x: zb.x, z: zb.z, k: 1.22, t: 0, T: 0.8 };
G.bossBurst = { x: zb.x, z: zb.z, t: 0.75 }; // the shockwave ring render.js draws
G.shake = { t: 0.5, mag: 7 }; G.boomFlash = 0.12;
boom(); splat(1, bodySize(zb)); growl(0.45, bodySize(zb), 1.4, 0.4); cheer(4, 1); voice('unbelievable', 0.5); // the biggest body in the game, at its lowest
const h = 1.8 * (zb.scale || 1.5);
for (let k = 0; k < 90; k++) parts.push({ // on top of killZombie's own burst: a full body's worth
x: zb.x, y: h * (0.2 + Math.random() * 0.7), z: zb.z,
vx: (Math.random() * 2 - 1) * 7, vy: 1 + Math.random() * 7, vz: (Math.random() * 2 - 1) * 7,
t: 0.7 + Math.random() * 0.9, dark: Math.random() < 0.4 });
for (let k = 0; k < 6; k++) { // and it leaves a mess nothing else on the court leaves
const a = Math.random() * 6.283, r = Math.random() * 2.4;
gore.push({ x: zb.x + Math.cos(a) * r, z: zb.z + Math.sin(a) * r * 0.6, r: 0.6 + Math.random() * 0.7, a: 0.9 });
if (gore.length > 40) gore.shift();
}
}
function killZombie(i, quiet) {
const zb = zombies[i];
haptic('medium'); // every weapon routes through here, so the kill is felt exactly once per kill
// The drill's ONE rule, read at the single point every weapon routes through — hooking the ball
// path instead would have the swish, the pellets and the blast silently exempt from the order.
const mul = GORE_MUL[zb.variant] || 1;
// BEFORE the early returns below: the ball machine is furniture but it is still a thing you put
// down, so it earns a row. The barrel is loot and scores nothing anywhere else in the game —
// keep that agreement here too. A chapter boss files under its DESIGN, so the screen can name
// the one that has been killing you.
// A MATCH OPPONENT FILES UNDER WHO IT IS, not under the generic body it wears: the screen is a
// list of who you have felled, and 'r' is a body, not a name. zb.foe is set by matchOpponent.
if (zb.variant !== 'o') {
tally(zb.foe ? zb.foe.key
: zb.variant === 'B' ? 'B' + Math.max(0, BOSS_ART.indexOf(zb.art))
: zb.variant, CAUSE);
award('kill'); // the leagues' clock, at the same single funnel — award() mutes no-stakes modes
}
zombies.splice(i, 1);
// THE DRILL RESOLVES HERE — after the splice, and WITHOUT returning. Both halves are load-bearing:
// splice first, because drillKill can draw a new combination on the spot (a double fault
// re-calls the survivors, an emptied batch brings in the next one) off whatever is standing in
// `zombies` — resolving first let the new combination name the body that was in the middle of
// dying, i.e. a round asking for a colour that is not on court;
// and no early return, because everything below is the DEATH — the blood decal, the pile, the
// particle burst, the splat and the headshot crack. The first cut returned here and bibbed
// bodies died silently and cleanly, which is not what this game does to anybody.
if (G.drill && zb.drill) drillKill(zb); // `drill` marks a batch member; eaters are never flagged
if (zb.variant === 'B') bossDeath(zb); // before endBossFight below, so the card can wait on it
if (zb.variant === 'o') { popBarrel(zb); return; } // a container: it pays out and scores NOTHING
if (zb.variant === 'm') { // furniture, not a body: no blood, no mound, no mop boy
wreckMachine(zb);
if (!G.horde && !G.clearing) splatPoint('P', quiet);
return;
}
if (G.interlude && zb.variant === 'r') resolveInterlude('P', 'body_destroyed'); // regicide ends the duel your way — any weapon counts
if (zb.variant === 'B') endBossFight(); // the chapter is over
// A BURNT BODY DOES NOT BURST. Fire kills by cooking, so there is nothing left to spray — it goes
// to dark grey ash instead of blood: same decal, same mound, same particles, all recoloured and a
// little smaller, plus a puff of smoke. Marked either by still burning or by `ashes`, which the
// flame ball sets before it kills (that kill happens before the body ever catches).
const ash = zb.burnT > 0 || zb.ashes;
const stains = Math.round(Math.sqrt(mul)); // advanced kills splash a scatter of stains, not one puddle
for (let k = 0; k < stains; k++) {
gore.push({ x: zb.x + (k ? (Math.random() * 2 - 1) * 0.9 : 0), z: zb.z + (k ? (Math.random() * 2 - 1) * 0.9 : 0),
r: (0.45 + Math.random() * 0.35) * Math.sqrt(mul) * (ash ? 0.8 : 1), a: ash ? 0.7 : 0.85, ash });
if (gore.length > 40) gore.shift();
}
const pl = piles.find(p => Math.hypot(p.x - zb.x, p.z - zb.z) < 0.6); // stack up where kills cluster
if (pl) { pl.size = Math.min(4, pl.size + Math.round(mul / 2 + 0.5)); if (!ash) pl.ash = false; }
else { piles.push({ x: zb.x, z: zb.z, size: Math.min(4, Math.round(mul / 2 + 0.5)), seed: Math.random() * 1e4, blobs: null, builtSize: 0, ash });
if (piles.length > 30) piles.shift(); }
// A BURNT BODY COLLAPSING IS ITS OWN MOMENT. It used to be over faster than a wet kill — fewer
// motes AND shorter ones — so the most spectacular way to die in the game was the least visible.
// Ash still SETTLES rather than sprays (low, slow, no arc), but there is more of it and it hangs
// roughly twice as long, which is what sells "he came apart into a heap" instead of "he blinked".
const n = Math.round((ash ? 34 : 30) * mul);
for (let k = 0; k < n; k++) { const dark = k >= n * 2 / 3; parts.push({ // ~1/3 dark & long-lived: settling gore
x: zb.x, y: 0.9, z: zb.z,
vx: (Math.random() * 2 - 1) * (ash ? 1.6 : 3.5), vy: (ash ? 0.6 : 1) + Math.random() * (ash ? 1.6 : 3.5),
vz: (Math.random() * 2 - 1) * (ash ? 1.6 : 3.5),
t: ash ? (dark ? 1.3 + Math.random() * 1.1 : 0.9 + Math.random() * 0.8)
: (dark ? 0.6 + Math.random() * 0.6 : 0.4 + Math.random() * 0.4), dark, ash,
}); }
if (ash) {
// the dust cloud: a low wide bloom at the feet where the body folded, and a thinner column
// lifting off it. Five tight puffs read as a wisp; this is the body's own volume going up.
for (let k = 0; k < 10; k++) smokePuff(zb.x, 0.25 + Math.random() * 0.5, zb.z, 1.0); // the ground bloom
for (let k = 0; k < 8; k++) smokePuff(zb.x, 0.8 + Math.random() * 1.5, zb.z, 0.5); // the column
for (let k = 0; k < 8; k++) parts.push({ // last embers off the collapse, dying as it settles
x: zb.x + (Math.random() * 2 - 1) * 0.5, y: 0.3 + Math.random() * 1.0, z: zb.z,
vx: (Math.random() * 2 - 1) * 1.1, vy: 1.0 + Math.random() * 1.4, vz: (Math.random() * 2 - 1) * 1.1,
t: 0.3 + Math.random() * 0.35, fire: true });
pock(0.4, 130);
}
else {
splat(1, bodySize(zb)); // the burst is pitched by the body too, so a boss comes apart LOW
sfx('headshot-' + (Math.random() < 0.5 ? 1 : 2), 0.6); // random headshot crack layered on the gore burst
}
sendBoy(zb.x, zb.z, true); // a boy comes out with the mop
// The chapter boss deliberately scores NOTHING: its reward is the chapter card and the parked game
// beat resuming. Letting it bank a point meant splatPoint's game-over branch and endBossFight both
// decided "what happens next" in the same frame and clobbered each other.
if (!G.horde && !G.clearing && zb.variant !== 'B') splatPoint('P', quiet); // horde = survival, clearing = mopping up an already-won game — neither scores
}
// ---- fire. The flame ball doesn't just kill what it touches, it starts an epidemic: the kill
// ignites its neighbours, and every burning body keeps igniting whatever it shambles past.
// Read-only over zombies[] (it only sets burnT), so it is safe to call from inside a kill loop.
function ignite(x, z, r) {
for (const zb of zombies) {
if (!zb || zb.burnT > 0) continue; // the boss DOES catch now — just at TUNE.bossBurnMul the rate,
// so fire is a real answer to a big body instead of a no-op
// remember WHAT lit it: a body that burns to death is credited to the flamer or the fire ball
// that started it, not to whatever happens to be firing when it finally goes down
if (Math.hypot(zb.x - x, zb.z - z) < r) {
zb.burnT = TUNE.burnTime; zb.burnBy = CAUSE; if (zb.hp === undefined) zb.hp = 1;
if (whumpT <= 0) { whump(); whumpT = 0.25; } // an epidemic lights many a frame: say it once
}
}
}
// Bank `amount` of damage on a multi-hit body and return true once it should DIE. Fractional amounts
// are legal (a machine-gun pellet chips a third of a point): they accumulate on zb.chip and only
// WHOLE points come off hp, because the wound skins and bleed() anchors switch on hp thresholds and
// a fractional hp would never match one. The caller decides HOW it dies — splatter() for the
// multi-kill fanfare, killZombie() for a quiet one — and must stop touching zb afterwards, because
// killZombie splices the array. This is the one place damage is applied, so a future boss with 20 hp
// needs no new code: every weapon is already proportional to it.
function wound(zb, amount, fx = true) {
zb.chip = (zb.chip || 0) + amount;
const ph = bossPhase(zb);
let took = 0;
while (zb.chip >= 1 && zb.hp > 0) { zb.chip -= 1; zb.hp--; took++; }
if (took && fx) { splat(0.5, bodySize(zb)); woundBurst(zb); }
// the boss FEELS it — throttled, because the gun chips it a dozen times a second
if (took && zb.variant === 'B' && bossHurtT <= 0) { growl(0.3, bodySize(zb), 0.45, 0.6); bossHurtT = 0.9; }
// A PHASED BOSS TURNING IS AN EVENT, not a silent change of numbers. Same body, new fight: the
// crowd comes up, the court shakes, and he says which set this is. Without the beat the champion's
// second gear would arrive as "the boss suddenly got faster" and read as the game glitching.
if (zb.hp > 0 && bossPhase(zb) > ph) {
const cry = bossPlan(zb).cry;
if (cry) G.flash = { text: cry, t: 1.6 };
G.shake = { t: 0.4, mag: 6 }; cheer(1.4, 0.7); moan(0.7);
}
return zb.hp <= 0;
}
// one burning body's frame: damage, spread, embers. Returns true if the fire killed it (the
// caller must then stop touching zb — killZombie splices, and splatPoint can cascade further).
function burnTick(zb, i, dt) {
zb.burnT -= dt;
CAUSE = zb.burnBy || 'fire';
ignite(zb.x, zb.z, TUNE.burnSpread);
// fx off: the embers below already ARE the feedback, a wet pop per cooked hp point would be noise
const dead = wound(zb, TUNE.burnDps * (isBoss(zb) ? TUNE.bossBurnMul : 1) * dt, false);
// Embers off the body, and SMOKE off the top of them. Two short-lived sparks read as a body
// sizzling; a burning man throws a column, so this is three embers with more spread and more
// life, plus a puff every few frames that rises and goes grey. The cap is what keeps a lit crowd
// from flooding the particle array — raised with the count so the last body still gets its fire.
if (parts.length < 200) {
for (let k = 0; k < 3; k++) parts.push({
x: zb.x + (Math.random() * 2 - 1) * 0.42, y: 0.4 + Math.random() * 1.6,
z: zb.z + (Math.random() * 2 - 1) * 0.2,
vx: (Math.random() * 2 - 1) * 0.8, vy: 1.5 + Math.random() * 1.2, vz: (Math.random() * 2 - 1) * 0.8,
t: 0.34 + Math.random() * 0.3, fire: true });
if (Math.random() < 0.25) smokePuff(zb.x, 1.5 + Math.random() * 0.7, zb.z, 0.35);
}
if (!dead) return false;
killZombie(i, !!G.horde);
return true;
}
// Phase A, every frame: they are tearing at you and it shows. A thin constant spray out of the
// pile plus a stain that keeps widening — "blood and gore flowing out" is a CONTINUOUS thing, not
// one burst, and the burst at EATEN_BURST lands on top of it.
export function eatenBleed(dt) {
const at = G.eatenAt; if (!at) return;
for (let k = 0; k < 2; k++) parts.push({
x: at.x + (Math.random() * 2 - 1) * 0.35, y: 0.5 + Math.random() * 0.8, z: at.z + (Math.random() * 2 - 1) * 0.3,
vx: (Math.random() * 2 - 1) * 3.2, vy: 1.5 + Math.random() * 3.5, vz: (Math.random() * 2 - 1) * 3.2,
t: 0.4 + Math.random() * 0.5, dark: Math.random() < 0.45 });
const g = gore.find(d => d.spreading);
if (g) g.r = Math.min(2.1, g.r + dt * 0.7);
else { gore.push({ x: at.x, z: at.z, r: 0.7, a: 0.9, spreading: true }); if (gore.length > 40) gore.shift(); }
if ((G.eatSfxT = (G.eatSfxT || 0) - dt) <= 0) { splat(0.45); G.eatSfxT = 0.28; } // wet work, on a loop
}
// phase B of the death animation (EATEN_BURST, config.js): the player IS the gore now. Reuses killZombie's machinery —
// the biggest mound on the court, and it stays visible under the summary scrim. That's the point.
export function playerGore() {
const at = G.eatenAt || { x: P.x, z: P.z };
gore.push({ x: at.x, z: at.z, r: 1.7, a: 1 }); if (gore.length > 40) gore.shift();
piles.push({ x: at.x, z: at.z, size: 4, seed: Math.random() * 1e4, blobs: null, builtSize: 0 });
if (piles.length > 30) piles.shift();
for (let k = 0; k < 90; k++) parts.push({
x: at.x, y: 1.0, z: at.z,
vx: (Math.random() * 2 - 1) * 5.5, vy: 1 + Math.random() * 6, vz: (Math.random() * 2 - 1) * 5.5,
t: 0.6 + Math.random() * 0.9, dark: k % 3 === 0 });
splat(1); boom();
G.shake = { t: 0.35, mag: 7 };
G.boomFlash = 0.08;
}
// a small dark spatter the instant a multi-hit shambler is wounded — subtle impact pop, NOT the
// kill spray (the ongoing thin trickle is bleed()'s job). Kept low per the settled blood feel.
// The player's own wound trickle. Same rule as the zombies': blood is NEVER painted into the player
// sprite (settled feel decision) — it is live particles, sparse, thin, and the flow scales with how
// far gone he is. Together with drawChar's procedural spatter this IS the health bar.
function bleedPlayer() {
if (parts.length > 95) return;
const lost = G.hpMax - G.hp;
for (let k = 0; k < lost; k++) {
if (Math.random() > 0.22) continue; // sparse: most frames each wound emits nothing
parts.push({
x: P.x + (k % 2 ? 0.22 : -0.2) + (Math.random() * 2 - 1) * 0.03,
y: 1.45 - k * 0.13, z: P.z + (Math.random() * 2 - 1) * 0.03,
vx: (Math.random() * 2 - 1) * 0.28, vy: 0.5 + Math.random() * 0.7, vz: (Math.random() * 2 - 1) * 0.26,
t: 0.3 + Math.random() * 0.3, dark: Math.random() < 0.55 });
}
}
function woundBurst(zb) {
for (let k = 0; k < 5; k++) parts.push({
x: zb.x + (Math.random() * 2 - 1) * 0.15, y: 1.4, z: zb.z,
vx: (Math.random() * 2 - 1) * 1.6, vy: 0.6 + Math.random() * 1.4, vz: (Math.random() * 2 - 1) * 1.6,
t: 0.35 + Math.random() * 0.3, dark: Math.random() < 0.6 });
}
// swishing the racket cleaves every shambler in reach — it's the panic button for when they're
// already on top of you, so it stays a full sweep. TUNE.meleeCool is the brake that stops it
// from being the default attack.
// The boss skip is NOT a gap in the damage model: it is unreachable by construction. The player is
// bounded at P_BOUNDS.zMin (netZ + 1.2 = 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. Left as a skip
// rather than "fixed", because a melee path to the boss would need him to cross the net first.
export function meleeSwipe(x, z, range) {
let hit = 0;
CAUSE = 'ball'; // the racket. No w_racket icon exists, and the swish IS the ball stroke — same row.
for (let i = zombies.length - 1; i >= 0; i--) {
const zb = zombies[i];
if (!zb || zb.variant === 'r') continue; // !zb: killZombie can sweep the court mid-loop
if (zb.variant === 'm' || isBoss(zb)) { // furniture and bosses don't cleave — they take a wound
if (Math.hypot(zb.x - x, zb.z - z) < range && wound(zb, TUNE.dmgBall)) { splatter(i); hit++; }
continue;
}
if (Math.hypot(zb.x - x, zb.z - z) < range) { killZombie(i, !!G.horde); hit++; }
}
// The swish deliberately does NOT touch incoming shots. It used to, as a pretoss fallback — but
// meleeSwipe runs FIRST in keyPressed, so it swallowed the very shot the new tap-to-parry was about
// to return. One answer to a thrown ball: swing at it.
if (hit) swish(0.35);
return hit;
}
function splatter(i, fanfare = true) { // the MAIN ball hit a zombie
const zx = zombies[i].x, zy = zombieH(zombies[i]) * 0.9, zz = zombies[i].z; // for the pops — killZombie splices
const sp0 = Math.hypot(ball.vx, ball.vz); // pace BEFORE the pierce tax, for a slice to keep
killZombie(i, !!G.horde);
ball.vx *= TUNE.zPierce; ball.vz *= TUNE.zPierce; // keep enough pace to plow through the next
// SLICE: struck again off this body, onto the next one, at FULL pace — the pierce tax is what a
// straight ball pays for plowing through meat, and a cut ball does not plow, it leaves. Only a
// KILL reaches here, so a wound ends the chain by simply never extending it, and no brute,
// steward or boss ever carries one on. See sliceCarom for why this is a single strike and not a
// steer. The new arc is solved from the CORPSE's position, not the ball's, because at this point
// in the frame the ball is still a hair short of the body it just burst.
// NOT IN THE DRILL. A slice leaves the corpse and strikes whatever it finds next — in a mode where
// hitting the wrong body IS the failure that is the game choosing your target for you, and choosing
// it badly. Oliver: "remove the ball slicing in this mode.. this leads to unwanted hits".
if (ball.chain > 0 && !G.drill) {
const nx = nearestTo(zx, zz, TUNE.sliceR, ball, TUNE.sliceArc * Math.PI / 180);
if (nx) {
ball.chain--;
sliceCarom(nx, Math.max(sp0, 14)); // floored: a ball that arrived slowly still leaves fast
pock(0.5, 1100 + 450 * ball.kills); // the cut, a step higher up the scale per link
} else ball.chain = 0; // nothing in reach: it plows on as a normal ball
}
if (!fanfare) return; // machine-gun pellets splatter but skip the streak fanfare / bomb meter
ball.kills++; // this shot's running streak
// splat micro-freeze: every ball kill stutters the world a touch, and the stutter grows with the
// streak, so a triple kill physically hitches three times. max(), never assignment — a bomb's own
// 0.18 s freeze must not be trimmed by the kills it causes.
G.hitstop = Math.max(G.hitstop, Math.min(0.09, 0.03 + 0.012 * ball.kills));
// escalating multi-kill fanfare — one shot, ≥2 zombies
// ...and the stat is banked on the TRANSITION to 2, so a massacre of six is one multi-kill
if (ball.kills === 2) { multiKill(); popText(zx, zy, zz, 'DOUBLE KILL'); cheer(1.6, 0.45); voice('double_kill', 0.4); }
else if (ball.kills === 3) { popText(zx, zy, zz, 'TRIPLE KILL'); cheer(2.2, 0.6); voice('triple_kill', 0.4); }
else if (ball.kills >= 4) { popText(zx, zy, zz, `MASSACRE ×${ball.kills}`); cheer(2.8, 0.7); voice('unbelievable', 0.4);
G.slowT = Math.max(G.slowT, 0.35); } // a massacre borrows the boss's slow-mo — the infra already runs every frame
}
// ---- horde wave: dense spawns + an auto-firing machine-gun racket (pellets, not the match ball)
function spawnHordeZombie() { // all from the far baseline, across the court — never off-screen at the sides
// a few stewards in the mix: the wave needs bodies that survive one pellet, or a grown horde is a
// uniform wall of the same thing dying at the same range
const v = Math.random() < TUNE.hordeSteward && G.runGames >= 4 ? 'd' : Math.random() < 0.5 ? 'a' : 'b';
spawnZombie((Math.random() * 2 - 1) * 5, -1.5 - Math.random(), v);
}
let mgTick = 0;
// One round, for every barrel-fed weapon there is. The WEAPONS row is stamped onto the pellet, so
// the loop below serves the machine gun, the beacon cannon, the flamethrower, the laser and the
// rocket without a second array or a second update — the only things that ever differed were a
// radius, a damage number, whether it stops at the first body, and what it does when it lands.
// Called with no argument by the HORDE, which is the plain machine gun.
export function firePellet(w = WEAPONS.gun) {
const spread = w.spread ?? (w.life ? TUNE.mgSpread * 2.4 : TUNE.mgSpread); // a short-lived round is a CONE
const a = (Math.random() * 2 - 1) * spread * Math.PI / 180, s = w.speed ?? 26; // straight up-court (-z)
const pe = { x: P.x, y: 0.9, z: P.z, vx: Math.sin(a) * s, vz: -Math.cos(a) * s,
t: w.life ?? 1.2, r: w.r ?? TUNE.mgPelletR, dmg: w.dmg ?? TUNE.dmgPellet, hp: w.hp ?? TUNE.dmgPelletHp,
pierce: !!w.pierce, boom: !!w.boom, ignite: w.ignite ? (w.igniteR ?? 1.8) : 0,
trail: w.trail || 0, trailR: w.trailR, jet: w.jet || 0, col: w.col, glow: w.glow,
wid: w.id || 'gun' }; // the round remembers which weapon threw it — the horde's plain gun has no row
// A LOBBED round: it is thrown, not sprayed. Solved so it comes down about where the crowd is, so
// "it throws itself" reads as aiming rather than as a flat stream that happens to be fat.
if (w.arc) {
const tgt = nearestAhead(); // something to actually throw AT
const dx = (tgt ? tgt.x : P.x + Math.sin(a) * 6) - P.x, dz = (tgt ? tgt.z : P.z - 12) - P.z;
const dist = Math.max(3, Math.hypot(dx, dz)), T = dist / s;
pe.vx = dx / T; pe.vz = dz / T;
pe.g = TUNE.gravity;
pe.vy = (0.35 - pe.y + 0.5 * pe.g * T * T) / T; // lands about knee height on the target
pe.t = T + 0.6;
pe.arc = 1;
}
// The BEACON changes colour every ball it throws. It is the showpiece: you should be able to tell
// it is running from the corner of your eye.
if (w.hue) pe.col = HUES[mgTick % HUES.length];
pellets.push(pe);
// each weapon announces itself: a low THUMP as the beacon lobs, a SWOOSH as a rocket leaves,
// and the gun's rapid tick every other round (at 12+/s every round buzzes the mixer)
if (w.sfx === 'thump') boom(0.3);
else if (w.sfx === 'swoosh') whoosh(0.3);
// the flamer had no case of its own and fell through to the gun's dry tick. Every THIRD round at
// rate 30 = 10 overlapping 0.26 s puffs a second, which sums to a continuous roar; firing one per
// round just stacks 30 gains on top of each other and clips.
else if (w.sfx === 'flame') { if (++mgTick % 3 === 0) flame(0.5); }
else if (++mgTick % 2 === 0) gunshot(); // the one ignite weapon is the flamer, which has its own case
}
const HUES = ['#ff6b6b', '#ffd166', '#7bffa0', '#6bd5ff', '#c08bff', '#ff8bd5'];
// The next body a slice is struck on to: nearest live thing to the kill it just made, and it may
// well be off to the side or short of it — that deflection is the whole shot. `arc` keeps it inside
// a believable angle off the ball's heading: a carom can physically go anywhere, but one that turns
// round and heads back down-court reads as a stray bounce, not as a cut.
// Furniture and barrels are not bodies and are never a target.
function nearestTo(x, z, maxD, b, arc) {
const head = Math.atan2(b.vx, b.vz);
let best = null, bd = maxD;
for (const zb of zombies) {
if ('mo'.includes(zb.variant)) continue;
const d = Math.hypot(zb.x - x, zb.z - z);
if (d >= bd) continue;
const da = ((Math.atan2(zb.x - b.x, zb.z - b.z) - head + Math.PI * 3) % (Math.PI * 2)) - Math.PI;
if (Math.abs(da) > arc) continue; // behind the ball: no arc gets there, so the chain ends
bd = d; best = zb;
}
return best;
}
// the best thing up-court to lob at: nearest body ahead of you, ignoring furniture you can walk past
function nearestAhead() {
let best = null, bd = 1e9;
for (const zb of zombies) {
if (!zb || zb.variant === 'o' || zb.z > P.z - 1.5) continue;
const d = Math.hypot(zb.x - P.x, zb.z - P.z);
if (d < bd) { bd = d; best = zb; }
}
return best;
}
export function spawnRacketZombie() { // one takes up residence on the far court — an opponent, of sorts
// He stiffens as the run goes on. hpMax is stored because the wound skins and bleed() anchors read
// a FRACTION of full health now — hardcoded `hp === 2` thresholds would leave a late-run boss
// looking pristine right up to the killing blow.
const hp = TUNE.racketHp + Math.floor(G.runGames * TUNE.racketHpPerGame);
zombies.push({ x: (Math.random() * 2 - 1) * 2.5, z: 3.5, v: 0, walkT: Math.random() * 9,
variant: 'r', side: 'O', facing: 1, pose: 'idle', poseT: 0, swingT: -1, swingCool: 0, runT: 0,
hp, hpMax: hp });
// (the IT HAS A RACKET card is gone — the racket in its hand is the announcement)
}
function endHorde() {
G.horde = null; pellets.length = 0;
if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // survive the wave → a racket zombie stays
setupPoint(); // serve loop resumes; any zombies still standing are yours to mop up
voice('quiet_dark', 0.5, 0.85);
}
function tickHorde(dt) {
const h = G.horde;
h.t -= dt;
// h.rate is stamped by startHorde and grows per set banked — every horde used to be the same
// twelve seconds at the same 2.5/s, so the fifth one was indistinguishable from the first.
if ((h.spawnT -= dt) <= 0) {
if (zombies.length < TUNE.zHordeCap) spawnHordeZombie(); // ...but bounded, see TUNE.zHordeCap
h.spawnT = 1 / (h.rate || TUNE.hordeRate);
}
if ((h.mgT -= dt) <= 0) { firePellet(); h.mgT = 1 / (h.mgRate || TUNE.mgRate); }
if (h.t <= 0) endHorde();
}
// bomb ball: everything inside bombRadius splatters at once (each kill still ++ball.kills so
// the Phase-2 fanfare escalates), plus a crater — big stain, size-3 pile, extra particles, boom.
export function explode(x, z, why = 'bomb') {
CAUSE = why; // the rocket detonates through here too, and it is its own row
haptic('heavy'); // the blast, before the kills it causes — heavy and medium throttle separately
for (let i = zombies.length - 1; i >= 0; i--) { // backwards: splatter() splices (and can sweep) the array
const zb = zombies[i];
if (!zb || Math.hypot(zb.x - x, zb.z - z) >= TUNE.bombRadius) continue;
// A blast used to delete the boss outright, ignoring his hp entirely — which made BOMB the only
// real answer to him and would have made any bigger boss meaningless. It's heavy damage now.
if (isBoss(zb)) { if (wound(zb, TUNE.dmgBomb)) splatter(i); }
else splatter(i);
}
gore.push({ x, z, r: 2.2, a: 1, scorch: true }); if (gore.length > 40) gore.shift(); // the crater — near-permanent
gore.push({ x, z, r: 2.0, a: 0.9 }); if (gore.length > 40) gore.shift();
// a ring of splatter thrown outward, so the blast paints the lawn instead of leaving one puddle
for (let k = 0; k < 6; k++) {
const a = Math.random() * 6.2832, r = TUNE.bombRadius * (0.35 + Math.random() * 0.6);
gore.push({ x: x + Math.cos(a) * r, z: z + Math.sin(a) * r, r: 0.7 + Math.random() * 0.7, a: 0.85 });
if (gore.length > 40) gore.shift();
}
const pl = piles.find(p => Math.hypot(p.x - x, p.z - z) < 0.6);
if (pl) pl.size = Math.min(4, pl.size + 3);
else { piles.push({ x, z, size: 4, seed: Math.random() * 1e4, blobs: null, builtSize: 0 }); if (piles.length > 30) piles.shift(); }
// the eruption itself: a fat, slow, high-arcing column of gore rather than a camera flash
for (let k = 0; k < 120; k++) parts.push({
x: x + (Math.random() * 2 - 1) * 0.4, y: 0.5, z: z + (Math.random() * 2 - 1) * 0.4,
vx: (Math.random() * 2 - 1) * 11, vy: 2 + Math.random() * 9, vz: (Math.random() * 2 - 1) * 11,
t: 0.7 + Math.random() * 1.1, dark: k % 3 === 0,
});
G.hitstop = 0.18; boom();
G.shake = { t: 0.45, mag: 9 }; // camera kick
// a warm, brief bloom instead of a white-out — the blinding frame was doing the work the gore
// should be doing, and on a phone it just hurt (see render.js for the colour)
G.boomFlash = 0.06;
if (G.idleT - G.lastOrderT > 3) { voice('order', 0.35); G.lastOrderT = G.idleT; } // the umpire loses composure
}
// wounded zombies bleed from every open wound — a fine, sparse trickle (a small spurt from the
// neck stump on the headless boss). Anchors are approximate body offsets [x, y, rise]; most
// frames a wound drips nothing, and the tight spread keeps each thread thin. Bounded low.
// Where each body opens up, one anchor per WOUND STAGE and cumulative: at stage n it bleeds from
// the first n. Read through the same config.woundStage call the skins are, and deliberately the same
// length as that variant's ZWOUND row, so the blood and the drawing it comes out of cannot drift —
// the two used to be written by hand against different constants. (The brute's old cumulative
// if-chain is what this replaces, and it reproduces it exactly. The steward gained a trickle here
// that his wound skin had always implied and never had.)
const BLEED = {
a: [[0.28, 1.35, 0.85]],
b: [[0.28, 1.35, 0.85]],
d: [[0.30, 1.62, 0.90]],
e: [[0.28, 1.35, 0.85], [0.05, 1.55, 1.60]], // gashed, then throat open
h: [[0.32, 1.72, 0.95], [-0.30, 1.68, 0.90], [0.05, 2.05, 1.80]],
g: [[-0.30, 1.95, 1.10], [0.42, 1.90, 1.00], [0.05, 2.35, 2.00], [0.00, 1.20, 0.70]], // arm, both, head, gut
k: [[-0.36, 2.30, 1.15], [0.50, 2.24, 1.05], [0.06, 2.80, 2.10], [0.00, 1.50, 0.75]], // the same, a head higher
};
// the kill fall's bleed-out: low anchors around the corpse itself, thin drops but many — the flow
// reads as the body emptying, not as a heavier version of the standing trickle (blood stays thin,
// the settled rule; this is more EMISSIONS from where the body actually is)
function gushProne(zb) {
if (parts.length > 150) return;
for (let i = 0; i < TUNE.killGush; i++) {
if (Math.random() > 0.75) continue;
parts.push({
x: zb.x + (Math.random() * 2 - 1) * 0.55, y: 0.22 + Math.random() * 0.3, z: zb.z + (Math.random() * 2 - 1) * 0.3,
vx: (Math.random() * 2 - 1) * 0.9, vy: 0.5 + Math.random() * 1.1, vz: (Math.random() * 2 - 1) * 0.5,
t: 0.35 + Math.random() * 0.35, dark: Math.random() < 0.55 });
}
}
// NETPLAY DEATH MATCH: the opponent is the LIVING O — a human sprite with no wound skins to climb
// (Oliver: "the normal opponent does not have wound states — this was the reason I said increased
// dynamic bleeding"). So the score reads on him as BLOOD: each point taken off him this game opens
// another anchor and thickens the flow, love → game point, and the burst (matchGameWon) resets it
// with the body. Same thin-drop discipline as bleed() — more EMISSIONS, never heavier drops.
const O_BLEED = [[0.12, 1.55, 0.9], [-0.22, 1.2, 0.8], [0.16, 0.9, 0.7]];
function bleedO() {
// THE BLOOD IS THEIR BLOOD, not our scoreboard (Oliver, 2026-08-22). This read points-this-game,
// which is a different clock from the one that actually kills them: it resets to zero every game,
// it ignores the heal a won game pays them, and it said "unhurt" to a friend two points from
// death. Their phone puts its real hp on the position stream (net.js), so the flow now tracks the
// pool that ends the match. The old count survives as the fallback for a build that sends none.
const o = G.net.opp;
const k = o && o.hp !== undefined
? Math.min(3, Math.ceil(Math.max(0, G.hpMax - o.hp) * 3 / Math.max(1, G.hpMax)))
: Math.min(3, score.pts[0]);
if (!k || G.hideO || parts.length > 120) return;
for (const w of O_BLEED.slice(0, k)) {
if (Math.random() > 0.14 * k) continue; // ramps: one light nick → gushing at game point
parts.push({
x: O.x + w[0] + (Math.random() * 2 - 1) * 0.04, y: w[1] + (Math.random() * 2 - 1) * 0.04, z: O.z + (Math.random() * 2 - 1) * 0.04,
vx: (Math.random() * 2 - 1) * 0.35, vy: w[2] * (0.6 + Math.random() * 0.5), vz: (Math.random() * 2 - 1) * 0.3,
t: 0.3 + Math.random() * 0.3, dark: Math.random() < 0.55 });
}
}
function bleed(zb) {
if (parts.length > 90) return;
let wounds;
const anchors = BLEED[zb.variant];
// the boss reads as a FRACTION of full health, not against hardcoded hp numbers: his hp scales
// with the run now (racketHpPerGame), so `hp === 2` would leave a late-run boss bleeding from
// nothing until the very last hit. Must stay in step with drawZombie's skin thresholds.
if (zb.variant === 'r' && zb.hp / (zb.hpMax || TUNE.racketHp) <= 0.34)
wounds = [[0.05, 1.5, 2.4], [0.4, 1.15, 0.9]]; // headless: neck spurt + torn arm
else if (zb.variant === 'r' && zb.hp / (zb.hpMax || TUNE.racketHp) <= 0.67)
wounds = [[0.4, 1.2, 1.0]]; // racket: torn-off arm
// Still sparse as the damage stacks — more ANCHORS, not a heavier flow (blood stays thin).
else if (anchors && woundStage(zb, anchors.length)) wounds = anchors.slice(0, woundStage(zb, anchors.length));
else return;
for (const w of wounds) {
if (Math.random() > 0.4) continue; // sparse: most frames this wound emits nothing
parts.push({
x: zb.x + w[0] + (Math.random() * 2 - 1) * 0.03, y: w[1] + (Math.random() * 2 - 1) * 0.03, z: zb.z + (Math.random() * 2 - 1) * 0.03,
vx: (Math.random() * 2 - 1) * 0.3, vy: w[2] * (0.6 + Math.random() * 0.5), vz: (Math.random() * 2 - 1) * 0.28,
t: 0.3 + Math.random() * 0.3, dark: Math.random() < 0.55 });
}
}
// Split out of updateZombies because particles are NOT a zombie feature: the ball's bounce dust
// fires in TENNIS too, and while this loop only ran in splatter, tennis dust was born, never
// ticked, and sat on the clay forever — the "marks where the ball hit" testers saw.
export function updateParts(dt) {
for (let i = parts.length - 1; i >= 0; i--) {
const p = parts[i];
p.t -= dt;
if (p.smoke) { p.vy = 0.55; p.vx *= 0.97; p.vz *= 0.97; } // smoke rises and spreads; it does not fall
else p.vy -= 14 * dt;
p.x += p.vx * dt; p.y += p.vy * dt; p.z += p.vz * dt;
if (p.y < 0) { p.y = 0; p.vy = 0; p.vx *= 0.5; p.vz *= 0.5; }
if (p.t <= 0) parts.splice(i, 1);
}
}
export function updateZombies(dt) {
moanT -= dt; whumpT -= dt; bossHurtT -= dt;
// FIRE HAS A SOUND OF ITS OWN: one crackle clock for the whole court, faster and a little louder
// the more is burning (sqrt, so a lit wave is dense, not deafening) — never one loop per body.
let lit = 0;
for (const zb of zombies) if (zb && zb.burnT > 0) lit++;
if (lit && (crackleT -= dt) <= 0) { crackle(Math.min(0.26, 0.12 + 0.035 * lit)); crackleT = Math.max(0.07, 0.2 / Math.sqrt(lit)); }
if (moanT <= 0) { moan(); moanT = 2.5 + Math.random() * 4; }
if (G.net && G.net.death && G.match) bleedO(); // the friend's damage, read as blood (see bleedO)
if (G.mode === 'splatter') { // transition spawns are scripted, not timed
G.splatT += dt;
if (G.horde) tickHorde(dt); // horde: dense spawns + machine gun, ignores the cap
else if (G.mgT > 0) { // machine-gun tin: horde firepower, regular rules
// The burst clock ticks in EVERY phase. It used to tick only during 'rally', so anything that
// moved the phase mid-burst (a duel calling setupPoint) froze mgT above zero forever — and
// `G.mgT > 0` blocks serving, so the game locked up with the gun racket stuck on screen.
G.mgT -= dt;
// whichever gun-like weapon is armed sets the rate and the round (see config.WEAPONS). A
const gw = WEAPONS[G.gunId] || WEAPONS.gun;
// NO PHASE GUARD. It used to require 'rally', and the beats that fire the moment a burst
// clears the court all park the phase in 'over' (a point, a won game, the mop-up) — so the
// weapon fell silent exactly when it had killed everything, which reads as the gun jamming
// ("it should just fire on"). A burst is its own control mode for its whole `len`, like a
// horde: the clock above is what ends it, never the beat playing behind it.
if (gw.rate && (G.mgCool -= dt) <= 0) { firePellet(gw); G.mgCool = 1 / gw.rate; }
if (G.mgT <= 0) { endBurst(); setupPoint(); } // window over: spend the tin, resume the serve loop
}
if (!G.horde && !G.changeover && !G.interlude && !G.clearing && !G.bossFight && !G.drill && !G.bench) { // breathers, duels, the mop-up after a won game, a boss fight, a drill round AND the sprite bench all get a quiet court (no new spawns)
spawnT -= dt;
// The spawn director: games finished sets both crowd size and tempo. Both curves used to hit a
// wall — the cap pinned at 14 by game 11 and the gap floored at 1.0 s by game 10, after which
// the game literally stopped changing. The gap is hyperbolic now so it keeps tightening on its
// own instead of running into a floor; zSpawnMin is only a safety stop.
// TEST MODE overrides the whole director with one fixed medium setting, so the sandbox is the
// same wave whether you triple-tapped in game 1 or game 40.
const cap = G.testMode ? TUNE.testZMax
: Math.min(TUNE.zMaxCap, TUNE.zMax + Math.floor(G.runGames * TUNE.zMaxPerGame));
if (zombies.length >= cap - 1 && !G.testMode) aside('ump_hindrance', 0.6); // the court is genuinely full
if (spawnT <= 0 && zombies.length < cap) {
spawnZombie((Math.random() * 2 - 1) * 4.8, -1.5 - Math.random(), pickVariant());
spawnT = G.testMode ? TUNE.testSpawnEvery
: Math.max(TUNE.zSpawnMin, TUNE.zSpawnEvery / (1 + G.runGames * TUNE.zSpawnAccel));
}
}
}
// ponytail: crude swept collision — grow the contact window by half the distance the ball
// covers this frame. A full-draw smash moves ~0.7 m per frame (1.6 m on a 25 fps device, where
// main.js clamps dt), which is wider than a shambler, so a discrete point test tunnels straight
// through bodies. Upgrade to a real segment-vs-circle test if the fudge ever reads as a phantom
// hit; at these speeds and radii it doesn't.
const ballSweep = ball.active ? Math.hypot(ball.vx, ball.vz) * dt * 0.5 : 0;
// The flame ball lights everything it FLIES PAST, not just what it kills. Without this the
// fire never actually spread: full pierce meant the ball killed its whole lane outright, and
// the only bodies left to catch were the ones it had already removed.
// The flame ball lights what it FLIES PAST, outside the body loop below — so it has to stamp the
// cause itself. Without this the trail's burn kills inherited whatever CAUSE was left from the
// previous frame and filed under 'ball'.
if (ball.active && ball.flame && ball.y < 2.6) { CAUSE = ball.wid; ignite(ball.x, ball.z, TUNE.burnBallR); }
// The umpire called time. The changeover is a MANDATORY breather for the player — no serving, no
// walking — so it is one for the dead too, which is the joke: they wait, because it's still a
// tennis match. They stand where they are, still bleeding, still burning, still lit by anything
// in the air, but nothing advances and nothing can eat you. (The alternative — letting the player
// walk freely during the beat — was tried and vetoed: he never walks freely during play either.)
const held = !!G.changeover;
if (G.hurtT > 0) G.hurtT -= dt;
if (G.hurtFlash > 0) G.hurtFlash -= dt;
if (G.hp < G.hpMax && G.mode === 'splatter') bleedPlayer();
for (let i = zombies.length - 1; i >= 0; i--) {
const zb = zombies[i];
if (!zb) continue; // a bomb detonation splices several out mid-loop and can gut the array under us
zb.walkT += dt;
const faceX0 = zb.x; // which way he is walking, for the sprite mirror in drawZombie
if (zb.burnT > 0 && burnTick(zb, i, dt)) continue; // the fire finished it — the array moved under us
// a chapter boss is excluded: bossAI owns BOTH of its axes, and leaving this in advanced it twice
if (!held && zb.variant !== 'B') zb.z += zb.v * (zb.burnT > 0 ? TUNE.burnPanic : 1) * dt;
// ...AND A BODY ON THE GROUND IS NOT STANDING, which this line already said and did not enforce.
// bleed()'s wound anchors are ABSOLUTE world heights — the headless neck spurt is pinned at
// y = 1.5 m — so a corpse lying flat through the fall kept spraying from where its neck used to
// be, two metres above itself. Oliver: "this looks like blood fountains midair." The falling body
// is the one G.matchFall owns; every other body on court bleeds as before.
if (!(G.matchFall && zb.variant === 'r')) bleed(zb); // open wounds bleed every frame they're standing
// ...but a game-winning body BLEEDS OUT where it lies: once the drop has landed it, the prone
// hold gushes from ground height until the burst (Oliver: "increased dynamic bleeding before
// exploding"). Deliberately NOT bleed() — its anchors are standing heights, and a corpse
// spraying from where its neck used to be is the "blood fountains midair" bug fixed above.
else if (G.matchFall.kill && G.matchFall.t > 0.25) gushProne(zb);
if (zb.shieldT > 0) zb.shieldT -= dt; // staggered chair comes back up when this runs out
if (zb.hitT > 0) zb.hitT -= dt; // boss contact cooldown (see TUNE.bossHitCool)
if (zb.retT > 0) zb.retT -= dt; // the champion's return stroke, held long enough to read
// ---- the two RANGED attackers. Both wind up visibly before firing (TUNE.machWind / drawZombie's
// charge glow) because a shot you cannot see coming is not a fairness contract, it is a gotcha.
if (!held && !G.interlude && (zb.variant === 'm' || zb.variant === 'B') && G.mode === 'splatter'
&& zb.rollTo === undefined) { // a cart on the roll cannot aim — that is the window to close on it
const boss = zb.variant === 'B', A = boss ? bossPlan(zb) : zb.art;
if (zb.salvo > 0) { // mid-volley: the rest of the balls are still coming
zb.salvoT -= dt;
// A gap of 0 means the WHOLE volley leaves in this one frame — the mascot's wall of five,
// which you dodge rather than parry. The loop still terminates: salvo counts down either way.
const gap = A?.gap ?? TUNE.bossSalvoGap, spread = A?.spread ?? 0.35;
while (zb.salvo > 0 && zb.salvoT <= 0) {
zb.salvo--; zb.salvoT += gap;
fireAtPlayer(zb.x, zb.z, (Math.random() * 2 - 1) * spread, A?.shot ?? TUNE.bossShotSpeed); // aimed at where
} // you are NOW, with only a hand's width of scatter
} else if (zb.windT > 0) {
zb.windT -= dt;
if (zb.windT <= 0) {
if (boss) { zb.salvo = A?.volley ?? TUNE.bossVolley; zb.salvoT = 0; growl(0.32, bodySize(zb), 0.18, 0.6); } // the volley starts next frame, on a bark
else fireAtPlayer(zb.x, zb.z, 0, TUNE.machSpeed);
}
} else if ((zb.fireT -= dt) <= 0) {
zb.fireT = boss ? (A?.cycle ?? TUNE.bossCycle) : TUNE.machCycle;
// windMax rides along for render: the telegraph glow fades in over the ACTUAL wind. It used
// to divide by TUNE.bossWind whatever the shooter, so the machine (0.9 s) and any design
// with wind > 0.5 spent the first stretch at an invalid negative alpha — full brightness,
// no ramp, on the one effect documented as the fairness contract.
zb.windT = zb.windMax = boss ? (A?.wind ?? TUNE.bossWind) : TUNE.machWind;
if (boss) growl(0.32, bodySize(zb), zb.windT, 1.35); // the tell: he draws breath for the whole wind-up
else pock(0.3, 1500); // the machine's tell: a mechanical click as it loads
}
}
if (G.mode === 'eaten' && G.eatenAt && zb.variant !== 'm' && G.eatenT < EATEN_SUMMARY) {
// You are down. Nothing else matters to them: every body on court converges on the spot and
// packs into a ring around it, and the ones already there feed (the bob is in drawZombie).
// This is the whole point of the death — you go under a CROWD, not under a puff of gore.
// Each one claims a SPOT around the body, kept from wherever it happened to be standing, so
// they form a ring and crowd in from all sides. Sending them all at the one point instead
// stacked every body on the same pixel and read as one fat zombie, not a swarm.
if (zb.eatA === undefined) {
zb.eatA = Math.atan2(zb.z - G.eatenAt.z, zb.x - G.eatenAt.x) + (Math.random() * 2 - 1) * 0.4;
zb.eatR = EATEN_FEED_R * (0.8 + Math.random() * 0.7);
}
const tx = G.eatenAt.x + Math.cos(zb.eatA) * zb.eatR;
const tz = G.eatenAt.z + Math.sin(zb.eatA) * zb.eatR * 0.55; // squashed in depth: the camera is low
const dx = tx - zb.x, dz = tz - zb.z, d = Math.hypot(dx, dz);
zb.feeding = d < 0.12;
if (!zb.feeding) {
const sp = zb.v * 1.8 * dt; // they hurry for a kill
zb.x += dx / d * Math.min(sp, d); zb.z += dz / d * Math.min(sp, d);
}
continue; // no shambling, no eating you twice, no crossing the baseline for a point
}
if (G.mode === 'eaten' && G.eatenAt) {
// The summary is up. They have finished with you: the pack breaks up and shambles away up-court
// instead of standing on the body twitching in a heap for as long as the screen is on.
zb.feeding = false;
const away = Math.sign(zb.x - G.eatenAt.x) || (zb.walkT % 2 < 1 ? 1 : -1);
zb.x += away * zb.v * 0.5 * dt;
zb.z -= zb.v * 0.7 * dt; // back the way they came
zb.face = away;
continue;
}
if (zb.variant === 'o') { // the barrel just rolls, turning at the sidelines. It never attacks.
if (!held && G.mode === 'splatter' && !G.interlude) {
zb.x += zb.dir * TUNE.barrelRoll * dt;
zb.roll += TUNE.barrelRoll * dt; // drawn spin, so it reads as ROLLING
if (Math.abs(zb.x) > 4.4) { zb.dir = -Math.sign(zb.x); zb.x = Math.sign(zb.x) * 4.4; }
}
}
else if (zb.variant === 'm') { // the machine wheels itself around instead of being bolted down
if (!held && G.mode === 'splatter' && !G.interlude) {
if (zb.rollTo !== undefined) {
const d = zb.rollTo - zb.x;
if (Math.abs(d) < 0.06) { zb.rollTo = undefined; zb.repoT = TUNE.machRepo * (0.7 + Math.random() * 0.6); }
else {
zb.x += Math.sign(d) * Math.min(Math.abs(d), TUNE.machRoll * dt);
if ((zb.rollT -= dt) <= 0) { pock(0.16, 150); if (Math.random() < 0.6) squeak(); zb.rollT = 0.4; } // castors grinding, and one of them squeaks: it announces itself
}
} else if ((zb.repoT -= dt) <= 0) { zb.rollTo = (Math.random() * 2 - 1) * 3.6; zb.rollT = 0; }
}
if (zb.hp < zb.hpMax * 0.5 && Math.random() < dt * 6) smokePuff(zb.x, 1.1, zb.z, 0.25); // wrecked: it smokes where it stands
}
else if (held || zb.variant === 'r') { /* nothing moves on a changeover; a duelling opponent owns his x */ }
else if (zb.variant === 'B') { if (G.mode === 'splatter' && !G.interlude) { bossAI(zb, dt); bossAdds(zb, dt); } }
// A DRILL BODY HOLDS ITS LANE. The homing below is a gore-mode behaviour — bodies converge on
// your x as they cross the net — and in the drill it undoes the spacing at exactly the moment
// spacing matters most: they arrive nicely spread and then bunch into an unreadable knot right in
// front of you, which is the "space them not too close" note coming back downstream. Their lane
// IS the puzzle here (it decides what screens what), so they walk it straight in with nothing but
// the wobble. They still reach you — the lane runs past the baseline — so the threat is unchanged.
else if (zb.drill) zb.x += Math.sin(zb.walkT * 1.7) * 0.12 * dt;
else if (G.mode === 'splatter' && zb.z > COURT.netZ) { // on YOUR half they close in — each on its own lane, not a column
const tx = P.x + zb.homeOff;
zb.x += Math.sign(tx - zb.x) * Math.min(Math.abs(tx - zb.x), zb.v * 0.7 * dt)
+ Math.sin(zb.walkT * 2.3) * 0.25 * dt; // shamble wobble — the approach reads organic, not tracked
} else
zb.x += Math.sin(zb.walkT * 1.7) * 0.15 * dt; // far half: aimless shamble
// CONTACT IS STILL INSTANT DEATH — settled decision, and the ball machine's chip damage does not
// soften it. Two threats that demand two different responses is the point. The machine ('m') is
// furniture and cannot eat you.
if (!held && G.mode === 'splatter' && !'mo'.includes(zb.variant) // furniture and barrels cannot eat you
&& !(G.dodgeI > 0) // ...and a sidestep gets you out from under one. It is the ONE escape from this
&& Math.hypot(zb.x - P.x, (zb.z - P.z) * 0.75) < TUNE.eatenR + (zb.variant === 'g' ? 0.2 : 0)) // z weighted down: depth is foreshortened, so a head-on shambler eats you when it LOOKS on top of you
killPlayer();
if (ball.active && ball.lastHit === 'P' && ball.y < 1.9) {
CAUSE = ball.wid; // re-stamped per body: burnTick above shares this loop and moves it
const d = Math.hypot(ball.x - zb.x, ball.z - zb.z);
if (d < hitR(zb) + ballSweep && !(zb.hitT > 0)) { // brute and boss are bigger targets
// the serve's deferred praise pops on the FIRST body the ball meets — this is the impact
// the word was waiting for (armed in hitServe, see praiseWord)
if (ball.praise) { popText(zb.x, zombieH(zb) * 0.8, zb.z, ball.praise, UI.warm); ball.praise = null; }
if (ball.bomb) { // detonate on first contact, whatever the pace
explode(ball.x, ball.z); ball.bomb = false;
spentBall(); break; // zombies mutated — stop this frame's loop
}
if (ball.flame) { // burns through anything it touches — no quality gate, no chair, no pace tax
if (isBoss(zb)) { // a boss takes a heavy wound AND catches light
zb.hitT = TUNE.bossHitCool; // ...and the ball is SPENT on it: a boss is not
ball.lastHit = 'X'; ball.vx *= 0.3; ball.vz *= 0.3; // removed, so an unconsumed pierce ball
if (wound(zb, TUNE.dmgFlame)) splatter(i); // re-wounded it every single frame
else zb.burnT = TUNE.burnTime;
continue;
}
const vx0 = ball.vx, vz0 = ball.vz, bx = zb.x, bz = zb.z;
zb.ashes = true; // burnt, not burst — see killZombie
splatter(i); ball.vx = vx0; ball.vz = vz0; // undo splatter's pierce tax: full pace through the lane
ignite(bx, bz, TUNE.burnIgniteR); // ...and everyone standing near the body goes up
continue;
}
if (isBoss(zb)) { // boss: splat-quality wounds it; soft balls the racket one rallies (racketAI)
// NOT IN THE MATCH. Regicide made sense in the duel (one sudden-death point, any weapon
// counts) but in a full game every hard serve that happened to meet the body was a free
// kill nobody aimed — Oliver: "the killing by smashing occurs too often. i cant really
// control it". A match ball NEVER damages the opponent; it passes through like any soft
// ball and racketAI plays it. The score is the only way through — which also makes the
// wound stages exactly the scoreboard, with no side-channel smearing them.
if ((ball.pow || 0) >= TUNE.zSplatQ && !G.match) {
zb.hitT = TUNE.bossHitCool; // belt-and-braces: this ball is spent below, but the cooldown
const dead = wound(zb, TUNE.dmgBall); // is what stops ANY lingering contact multi-wounding
pock(0.6, 300);
ball.vx *= 0.3; ball.vz *= 0.3; ball.lastHit = 'X'; // spent on the hit
if (dead) { splatter(i); continue; } // killing blow: full gore + point
}
// ...AND THE CHAMPION RETURNS THE REST. Every other body in the game can be worn down with
// soft balls; the final boss sends anything under splatter quality straight back at your
// head, which is what makes him the one you have to out-HIT. The ball is spent on his
// racket and comes back as an incoming shot — parry it, swish it away, or step aside.
else if (bossPlan(zb).rally) {
fireAtPlayer(zb.x, zb.z, (Math.random() * 2 - 1) * 0.4, bossPlan(zb).shot ?? TUNE.bossShotSpeed);
zb.retT = 0.3; // the throw frame, so the return is drawn
ball.vx *= 0.2; ball.vz *= 0.2; ball.lastHit = 'X';
}
continue; // soft balls pass through ON PURPOSE, for racketAI to return — that rally IS the
// feedback that it wasn't hard enough, so no clank here; see PROJECT.md
}
if (zb.variant === 's' && ball.vz < 0 && !(zb.shieldT > 0)) { // chair up, ball at its face: BLOCKED
zb.shieldT = TUNE.shieldDown; // ...but the block staggers it — the chair drops, kill window opens
zb.z -= TUNE.zPushback * 0.5;
pock(0.9, 1800); // wooden clank
ball.vx *= 0.4; ball.vz *= -0.2; ball.lastHit = 'X'; // ball caroms off the chair, spent
continue;
}
if (zb.variant === 'f') { splatter(i); continue; } // the sprinter is GLASS — any touch, any quality, it bursts
// ANY multi-hit body soaks, and hp is the ONLY thing that says how many. This used to be a
// chain of per-variant branches — brute+machine, then shamblers — and every body NOT named
// in it fell through to "splat-quality kills outright" below. That silently one-shot the
// steward (5 hp, commented "a brute's worth of hp") from the day he shipped, and then the
// ball boy, bouncer and roller man the moment they existed: Oliver's "the bigger guys were
// way too easy to kill". Pellets and melee always read hp properly; only the BALL skipped it.
if ((zb.hpMax || 0) > 1) {
const meat = zb.variant !== 'm'; // the machine rings and does not bleed
const heavy = 'gmhk'.includes(zb.variant); // a heavy body is not shoved back by a ball
const sz = bodySize(zb);
if ((ball.pow || 0) >= TUNE.zSplatQ) {
if (--zb.hp <= 0) { splatter(i); continue; } // the killing blow
if (meat) woundBurst(zb); // a bloody wound, not a death
// meat THUDS (wet, and pitched by how big the body is), the machine still rings
if (meat) { thud(0.65, sz); splat(0.3, sz); } else pock(0.6, 700);
if (!heavy) zb.z -= TUNE.zPushback * 0.5;
ball.vx *= 0.3; ball.vz *= 0.3; ball.lastHit = 'X'; continue; // ball spent on the wound
}
if (!heavy) zb.z -= TUNE.zPushback; // soft ball staggers the light ones, bounces off the rest
if (meat) thud(0.4, sz); else pock(0.6, 500); // a soft ball off a body is a duller version of the same hit
ball.vx *= 0.6; ball.vz *= 0.6; ball.lastHit = 'X'; continue;
}
// chair-down shield zombies (and anything hit from behind) obey the normal rules:
if ((ball.pow || 0) >= TUNE.zSplatQ) { splatter(i); continue; }
zb.z -= TUNE.zPushback; // weak sitters only stagger them
thud(0.4, bodySize(zb));
ball.vx *= 0.6; ball.vz *= 0.6; ball.lastHit = 'X'; // spent — one stagger per ball
}
}
if (Math.abs(zb.x - faceX0) > 0.004) zb.face = Math.sign(zb.x - faceX0); // sticky: a body standing still keeps facing where it last walked
if (zb.z >= COURT.L + 0.7) {
// A BATCH BODY THAT GETS PAST YOU IS A FAILURE, not a free despawn. Drill bodies hold their
// lane, so one in a lane you are not standing in walks the whole court and off the back
// without ever touching you — and since the sequence names it, the round is left asking for a
// body that no longer exists. That is the un-winnable state the whole mode is built to
// prevent, and it only appeared once the roster became the alphabet: types move at different
// speeds, so the runner leaves long before the roller. It counts as caught.
if (G.drill && zb.drill) { zombies.splice(i, 1); drillCaught('GOT PAST YOU'); continue; }
aside('ump_unsportsmanlike', 0.5); zombies.splice(i, 1); if (G.mode !== 'eaten' && !G.horde && !G.clearing) splatPoint('O');
} // it reached your baseline (during a mop-up, a straggler that reaches the line just despawns — no point for them)
}
for (let i = pellets.length - 1; i >= 0; i--) { // gun rounds flat and fast; lobbed ones ride an arc
const pe = pellets[i];
// A rocket blast can bank the last kill of a game, and the beat that follows (a duel, a chapter
// boss) empties this array — so every remaining index reads undefined and the frame throws. Same
// guard, and the same reason, as the one in the shots loop and the body loop.
if (!pe) continue;
CAUSE = pe.wid;
pe.t -= dt; pe.x += pe.vx * dt; pe.z += pe.vz * dt;
let hit = false, landed = false;
if (pe.arc) { // a thrown round: it falls, and landing is a hit
pe.vy -= pe.g * dt; pe.y += pe.vy * dt;
if (pe.y <= 0.05) { pe.y = 0.05; landed = true; }
}
// A JET PLUME, not a shower of sparks: the exhaust is laid down tight on the flight line,
// barely moving, so it reads as a rocket motor drawing a line through the air. Scattered
// sparks with their own velocities looked like a firework, which is not what a rocket does.
if (pe.jet && parts.length < 240)
for (let k = 0; k < pe.jet; k++) {
const b = (k + Math.random()) / pe.jet; // spread ALONG this frame's step
parts.push({
x: pe.x - pe.vx * dt * b + (Math.random() * 2 - 1) * 0.05,
y: pe.y - (pe.vy || 0) * dt * b + (Math.random() * 2 - 1) * 0.05,
z: pe.z - pe.vz * dt * b + (Math.random() * 2 - 1) * 0.05,
vx: 0, vy: 0.25, vz: 0, // it hangs and lifts, it does not fly off
t: 0.30 + Math.random() * 0.30, jet: 1, spark: '#fff2c0' });
}
// ...and a FINE thread for the beacon: one small mote a frame, tight to the ball, so the ball
// itself is the bright thing and the trail only says where it has been.
// ...and a FINE thread for the beacon: one small mote a frame, tight to the ball. An IGNITE
// round (the flamer) throws fat embers instead — same loop, but `fire` motes that are 2px and
// warm rather than 1px and flat, they LIFT as they burn out, and they live long enough to leave
// a body of flame hanging in the air behind the stream instead of a dotted line.
if (pe.trail && parts.length < 320)
for (let k = 0; k < pe.trail; k++) parts.push({
x: pe.x + (Math.random() * 2 - 1) * (pe.trailR ?? 0.12),
y: pe.y + (Math.random() * 2 - 1) * (pe.trailR ?? 0.12),
z: pe.z + (Math.random() * 2 - 1) * (pe.trailR ?? 0.12),
vx: (Math.random() * 2 - 1) * (pe.ignite ? 0.5 : 0), vz: (Math.random() * 2 - 1) * (pe.ignite ? 0.5 : 0),
vy: pe.ignite ? 0.9 + Math.random() * 0.8 : 0.12,
t: pe.ignite ? 0.30 + Math.random() * 0.34 : 0.16 + Math.random() * 0.16,
fine: pe.ignite ? 0 : 1, fire: pe.ignite ? 1 : 0, spark: pe.ignite ? 0 : (pe.col || '#fff') });
for (let j = zombies.length - 1; j >= 0; j--) {
const zb = zombies[j];
if (!zb) continue;
if (Math.hypot(zb.x - pe.x, zb.z - pe.z) < hitR(zb) + (pe.r ?? TUNE.mgPelletR)) {
// Pellets used to skip the boss entirely, so a GUN burst (and every horde) did literally
// nothing to him with no feedback at all. They CHIP him now: dmgPellet is fractional, so
// three rounds take a point off and the gun is a slow, committed answer rather than a no-op.
// EVERY new weapon comes through here, which is why none of them can silently no-op on a
// boss the way three of the five once did.
if (pe.ignite) ignite(pe.x, pe.z, pe.ignite); // the flamer lights the spot, and fire spreads itself
// A round with no impact damage (the flamethrower) only LIGHTS what it touches — the burn
// does the killing. Without this guard `hp -= 0` fell straight through to killZombie and the
// flame cone deleted the crowd on contact, which is a machine gun that happens to be orange.
const dmgHp = pe.hp ?? TUNE.dmgPelletHp, dmgBoss = pe.dmg ?? TUNE.dmgPellet;
if (isBoss(zb)) { if (dmgBoss > 0 && wound(zb, dmgBoss, false)) killZombie(j, true); else pock(0.45, 240); }
else if (dmgHp <= 0) { /* fire only: it is already alight from the ignite above */ }
else if ((zb.hp -= dmgHp) > 0) pock(0.5, 260); // only brutes soak now; a shambler drops to one round
else killZombie(j, true);
if (pe.boom) { explode(pe.x, pe.z, pe.wid); hit = true; break; } // the rocket takes the crowd, not the body
hit = !pe.pierce; // a laser keeps going down the whole lane
if (hit) break;
}
}
// A thrown round that reaches the ground goes off THERE, whether or not it touched a body —
// "like a bomb, the projectile is a rocket". Without this a rocket that missed simply expired.
if (!hit && landed) {
if (pe.boom) explode(pe.x, pe.z, pe.wid);
else if (pe.ignite) ignite(pe.x, pe.z, pe.ignite);
hit = true;
}
if (hit || pe.t <= 0) pellets.splice(i, 1);
}
if (G.beam) { G.beam.t -= dt; if (G.beam.t <= 0) G.beam = null; } // the laser's wedge fades and is gone
// ---- INCOMING hostile balls (ball machine, chapter boss volleys). Three outcomes, in this order:
// a timed swing inside the volley window RETURNS it as your own ball flying up-court, the panic
// swish (meleeSwipe, above) merely bats it away, and reaching you unanswered costs a point of hp.
for (let i = shots.length - 1; i >= 0; i--) {
const s = shots[i];
// A shot landing the KILLING blow routes through killPlayer, which empties this array from
// under us — every remaining index then reads undefined and the frame throws. Same guard, and
// the same reason, as the `if (!zb) continue` in the body loop above.
if (!s) continue;
s.t -= dt; s.vy -= s.g * dt;
s.x += s.vx * dt; s.y += s.vy * dt; s.z += s.vz * dt;
let done = s.t <= 0 || s.y < -0.25 || Math.abs(s.x) > 8 || s.z > COURT.L + 2;
// The PARRY keeps the 0.8 depth weighting — a racket sweep really is wider than it is deep.
// The HIT does not: weighting depth shrinks it, so contact registered while the ball was still
// most of a metre up-court. That mismatch between what you saw and what hit you was the bug.
const dP = Math.hypot(s.x - P.x, (s.z - P.z) * 0.8);
const dHit = Math.hypot(s.x - P.x, s.z - P.z);
if (!done && P.swingT >= 0 && P.swingT <= TUNE.parryWindow
&& dP < TUNE.parryReach * bonus().parry && s.y < CONTACT.maxY) {
// the parry: it becomes an `extras` clone — that array is already ballistic and already kills
// zombies, so "returned shot is a live weapon" costs nothing to build.
// It is a GLANCING blow, not a mirror. Reversing the incoming line sent every return straight
// back up the line it arrived on — which is where the thrower is standing — so a boss was a
// free wound per throw. Where you meet the ball across the racket face now sets the angle:
// hitting it back INTO him is a shot you have to place your feet for.
const off = Math.max(-1, Math.min(1, (s.x - P.x) / (TUNE.parryReach * bonus().parry))); // -1..1 across the face
const ang = (off * TUNE.parryDeflect + (Math.random() * 2 - 1) * TUNE.parryJitter) * Math.PI / 180;
const sp = Math.hypot(s.vx, s.vz) * 1.15;
extras.push({ x: s.x, y: s.y, z: s.z, g: TUNE.gravity, pow: TUNE.parryPow, t: 2.5,
vx: Math.sin(ang) * sp, vy: 4.2, vz: -Math.cos(ang) * sp });
pock(0.85, 1300); popText(s.x, s.y + 0.4, s.z, 'RETURNED'); cheer(0.8, 0.3);
done = true;
}
if (!done && dHit < TUNE.shotHitR && s.y < TUNE.shotHitY) { hurtPlayer(TUNE.machDmg); done = true; }
if (done) shots.splice(i, 1);
}
for (let i = extras.length - 1; i >= 0; i--) { // triple-serve side balls: full ballistic clones
const e = extras[i];
if (!e) continue; // same as pellets/shots: a kill here can start a beat that empties the array
CAUSE = e.wid || 'ball'; // a triple's wings; a parried machine ball is just a ball
e.t -= dt; e.vy -= (e.g || TUNE.gravity) * dt; // e.g: a clone flies under the SAME topspin as the ball it came from
e.x += e.vx * dt; e.y += e.vy * dt; e.z += e.vz * dt;
if (e.y <= 0 && e.vy < 0) { e.y = 0; e.vy = -e.vy * TUNE.bounceY; e.vx *= TUNE.bounceXZ; e.vz *= TUNE.bounceXZ; }
let dead = e.t <= 0 || Math.abs(e.x) > 7.5 || e.z < -2 || e.z > 25.5;
if (!dead && e.y < 1.9) for (let j = zombies.length - 1; j >= 0; j--) {
const zb = zombies[j];
if (!zb) continue; // killZombie can sweep the court mid-loop
if (Math.hypot(zb.x - e.x, zb.z - e.z) < hitR(zb)
+ Math.hypot(e.vx, e.vz) * dt * 0.5) { // same sweep: clones inherit the smash pace
// Side balls used to pass THROUGH the boss doing nothing at all, which is most of why a
// TRIPLE serve read as "he can't be hurt". They wound him like the main ball now.
if (isBoss(zb)) {
if (e.pow >= TUNE.zSplatQ) { if (wound(zb, TUNE.dmgSide)) killZombie(j, !!G.horde); }
else thud(0.4, bodySize(zb));
}
else if (zb.variant === 's' && e.vz < 0 && !(zb.shieldT > 0)) { zb.shieldT = TUNE.shieldDown; pock(0.9, 1800); } // chair blocks these too
else if (e.pow >= TUNE.zSplatQ || zb.variant === 'f') {
if (zb.hp > 1) { zb.hp--; thud(0.55, bodySize(zb)); splat(0.25, bodySize(zb)); } // wounds brutes AND shamblers
else killZombie(j, !!G.horde);
} else { zb.z -= TUNE.zPushback; thud(0.4, bodySize(zb)); }
dead = true; break; // a side ball is spent on its first touch
}
}
if (dead) extras.splice(i, 1);
}
updateParts(dt);
const boy = G.boy; // the boy mops any gore he passes over
for (let i = gore.length - 1; i >= 0; i--) {
const d = gore[i];
// He scrubs HARD only while stopped and working the mop; walking past barely touches it, so
// the stain visibly dies under him rather than fading as he drifts by.
if (boy && Math.abs(boy.x - d.x) < 0.8 && Math.abs(boy.z - d.z) < 1.3)
d.a -= dt * (d.scorch ? 0.16 : boy.mopT > 0 ? 0.7 : 0.2); // craters scar the lawn far longer
if (d.a <= 0) gore.splice(i, 1);
}
for (let i = piles.length - 1; i >= 0; i--) { // piles shave far slower than stains — net growth wins
const pl = piles[i];
if (boy && Math.abs(boy.x - pl.x) < 0.7 && Math.abs(boy.z - pl.z) < 1.2) pl.size -= dt * 0.25;
if (pl.size <= 0.3) piles.splice(i, 1);
}
}