Phase 5: horde waves + machine-gun racket

Winning your 2nd splatter game (G.splatGames) — or triple-tapping the
umpire in splatter (forceZombies now calls startHorde) — launches a 12s
horde (TUNE.hordeLen). During a horde: the match ball is gone and taps do
nothing; the racket auto-fires pellets straight up-court at TUNE.mgRate
(6/s, ±4° spread, 26 m/s, flat, 1.2s life) and the thumb DRAG steers you
(auto-assist disabled). Zombies pour in at TUNE.hordeRate (2.5/s) from the
far baseline and the sides, ignoring zMax; the speed ramp still applies.

Pellets live in a new zombies.js `pellets` array, kill on contact (always
splatter-quality). splatter() gains a fanfare flag — pellet kills make
gore/piles/points but skip the multi-kill streak so a horde doesn't spam
MASSACRE. splatPoint() gains a quiet flag: during a horde the umpire only
calls a score every >2s (game-win lines always speak); the HUD score keeps
ticking. Horde ends → setupPoint() resumes serving, voice('quiet_dark');
leftover zombies stay for you to mop up. Eaten stays lethal during hordes
(a zombie reaching you ends it and clears the swarm). Pellets render as
2×2 yellow tracers with a ground shadow; serve prompt suppressed while
G.horde. window.T exposes pellets + forceZombies.

NOTE for Oliver: per spec every game won after the 2nd triggers a horde,
and games won by the machine gun mid-horde count too, so play gets very
horde-heavy after game 2 — flag if you want a cooldown or a knob.

Verified via window.T: cheat + 2nd-game both launch the horde (1st does
not); ~29 spawns over exactly 12s; pellets stream (7 in flight) and clear
a lane, building piles/gore; 4 points scored but the umpire speaks once
(rate-limited); horde ends cleanly back to pretoss; eaten still fires
mid-horde; regression ladder green — tennis 11/12, Phase 1 flick-serve,
Phase 2 DOUBLE→TRIPLE, Phase 4 MASSACRE ×4; no console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 4.8 committed 2026-07-11 22:26:22 +02:00
1 parent 06025bdccf
commit bf9553bd49
6 files changed
+84 -21

No files matched your search

+3
View File
@@ -27,6 +27,9 @@ export const TUNE = {
bombEvery: 8, // splatter kills between bomb-ball rewards bombEvery: 8, // splatter kills between bomb-ball rewards
bombMax: 3, // bomb balls you can stock at once bombMax: 3, // bomb balls you can stock at once
bombRadius: 2.5, // m blast radius of a bomb ball bombRadius: 2.5, // m blast radius of a bomb ball
hordeLen: 12, // s a horde wave lasts
hordeRate: 2.5, // zombies spawned per second during a horde
mgRate: 6, // machine-gun pellets fired per second during a horde
missHud: 1, // show the last miss reason on screen (full log: T.G.missLog) missHud: 1, // show the last miss reason on screen (full log: T.G.missLog)
}; };
+21 -8
View File
@@ -178,20 +178,32 @@ function pointTo(side, reason) {
G.serveNum = 1; G.serveNum = 1;
} }
// splatter-mode scoring: points land mid-flight without interrupting play // splatter-mode scoring: points land mid-flight without interrupting play.
export function splatPoint(side) { // quiet (during a horde) rate-limits the umpire so he doesn't call every rapid-fire point.
export function splatPoint(side, quiet = false) {
const gameOver = advancePoint(side); const gameOver = advancePoint(side);
if (gameOver) { if (gameOver) {
voice(side === 'P' ? 'game_set_massacre' : 'game_opponent', 0.5); voice(side === 'P' ? 'game_set_massacre' : 'game_opponent', 0.5); // game-win lines always speak
cheer(2.2, 0.5); cheer(2.2, 0.5);
} else { if (side === 'P' && ++G.splatGames >= 2) startHorde(); // 2nd splatter game (and every one after): the horde
voice(scoreKey(), 0.45); } else if (!quiet || G.idleT - G.lastScoreT > 2) {
if (side === 'P') cheer(0.7, 0.25); voice(scoreKey(), 0.45); G.lastScoreT = G.idleT;
if (side === 'P' && !quiet) cheer(0.7, 0.25);
} }
} }
// hidden shortcut: tapping the umpire three times summons them early // the swarm keeps coming: no serving, the racket auto-fires, the thumb drag steers you
function startHorde() {
if (G.horde) return;
G.horde = { t: TUNE.hordeLen, spawnT: 0, mgT: 0 };
ball.active = false; G.phase = 'rally'; // ball gone; 'rally' lets the movement block steer P
voice('remain_seated', 0.3);
G.flash = { text: 'THEY KEEP COMING', t: 1.2 };
}
// hidden shortcut: tapping the umpire three times summons the horror early
export function forceZombies() { export function forceZombies() {
if (G.mode === 'splatter') { startHorde(); return; } // in splatter the cheat forces a horde wave
if (G.mode !== 'tennis') return; if (G.mode !== 'tennis') return;
ball.active = false; ball.active = false;
startTransition(); startTransition();
@@ -363,6 +375,7 @@ function trySwing(ch, isAI) {
export function keyPressed(code) { export function keyPressed(code) {
if (code !== 'Space') return; if (code !== 'Space') return;
audio(); // unlock on first gesture audio(); // unlock on first gesture
if (G.horde) return; // horde: the racket auto-fires, taps do nothing — the thumb drag steers you
// splatter: a tap/flick during the brief 'over' beat arms the next serve immediately — // splatter: a tap/flick during the brief 'over' beat arms the next serve immediately —
// reset inline, then fall through to pretoss so the finger is the only cadence limit // reset inline, then fall through to pretoss so the finger is the only cadence limit
if (G.mode === 'splatter' && G.phase === 'over') { ball.active = false; setupPoint(); } if (G.mode === 'splatter' && G.phase === 'over') { ball.active = false; setupPoint(); }
@@ -421,7 +434,7 @@ export function update(dt) {
P.x += mx * TUNE.playerSpeed * slow * dt; P.x += mx * TUNE.playerSpeed * slow * dt;
P.z += mz * TUNE.playerSpeed * slow * dt; P.z += mz * TUNE.playerSpeed * slow * dt;
let assist = 0; let assist = 0;
if (!mx && !mz && TUNE.autoAssist > 0 && ball.active) { if (!mx && !mz && TUNE.autoAssist > 0 && ball.active && !G.horde) { // horde: you aim by hand, no assist
const chasing = ball.lastHit === 'O'; const chasing = ball.lastHit === 'O';
// no `slow` here: tapping must not freeze your legs, or anticipating a shot loses it // no `slow` here: tapping must not freeze your legs, or anticipating a shot loses it
const rate = TUNE.playerSpeed * TUNE.autoAssist * dt * (chasing ? 1 : 0.55); const rate = TUNE.playerSpeed * TUNE.autoAssist * dt * (chasing ? 1 : 0.55);
+2 -2
View File
@@ -8,7 +8,7 @@ import { P, O, ball, score, G, setupPoint } from './state.js';
import { update, keyPressed, solveShot, inServeBox, inCourt, forceZombies } from './gameplay.js'; import { update, keyPressed, solveShot, inServeBox, inCourt, forceZombies } from './gameplay.js';
import { fitViewport, render, proj } from './render.js'; import { fitViewport, render, proj } from './render.js';
import { voice } from './audio.js'; import { voice } from './audio.js';
import { zombies, spawnZombie, piles, gore, parts } from './zombies.js'; import { zombies, spawnZombie, piles, gore, parts, pellets } from './zombies.js';
import { cv } from './dom.js'; import { cv } from './dom.js';
import './input.js'; import './input.js';
@@ -54,6 +54,6 @@ cv.addEventListener('pointerdown', e => {
requestAnimationFrame(frame); requestAnimationFrame(frame);
// exposed for in-browser sanity checks and simulated playtests // exposed for in-browser sanity checks and simulated playtests
window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, piles, gore, parts, solveShot, inServeBox, inCourt, proj, setupPoint, window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, piles, gore, parts, pellets, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies,
step: dt => update(dt), render, press: keyPressed, step: dt => update(dt), render, press: keyPressed,
get phase() { return G.phase; }, set phase(v) { G.phase = v; } }; get phase() { return G.phase; }, set phase(v) { G.phase = v; } };
+8 -2
View File
@@ -4,7 +4,7 @@ import { cv, ctx } from './dom.js';
import { TUNE, COURT, PLAYER_H, BOY_H, UMP_H, BOUNCE_IDLE, SPRITE_H } from './config.js'; import { TUNE, COURT, PLAYER_H, BOY_H, UMP_H, BOUNCE_IDLE, SPRITE_H } from './config.js';
import { P, O, ball, score, crowd, G } from './state.js'; import { P, O, ball, score, crowd, G } from './state.js';
import { predictLanding } from './gameplay.js'; import { predictLanding } from './gameplay.js';
import { zombies, gore, piles, parts } from './zombies.js'; import { zombies, gore, piles, parts, pellets } from './zombies.js';
// ---------------------------------------------------------------- palette ---- // ---------------------------------------------------------------- palette ----
// tennis palette -> drained splatter palette; the transition lerps between them // tennis palette -> drained splatter palette; the transition lerps between them
@@ -301,6 +301,12 @@ export function render() {
ctx.fillStyle = pt.dark ? 'rgba(96,14,14,0.9)' : 'rgba(170,22,22,0.9)'; ctx.fillStyle = pt.dark ? 'rgba(96,14,14,0.9)' : 'rgba(170,22,22,0.9)';
ctx.fillRect(pp.x | 0, pp.y | 0, 2, 2); ctx.fillRect(pp.x | 0, pp.y | 0, 2, 2);
} }
// machine-gun pellets: flat tracers with a tiny ground shadow
for (const pe of pellets) {
const sh = proj(pe.x, 0, pe.z); drawShadow(sh.x, sh.y, Math.max(1, sh.s * 0.04), Math.max(1, sh.s * 0.02), 0.25);
const pp = proj(pe.x, pe.y, pe.z);
ctx.fillStyle = '#f8e048'; ctx.fillRect((pp.x | 0) - 1, (pp.y | 0) - 1, 2, 2);
}
if (G.mode !== 'eaten') drawChar(P); // there is no P anymore if (G.mode !== 'eaten') drawChar(P); // there is no P anymore
// HUD — scoreboard // HUD — scoreboard
@@ -327,7 +333,7 @@ export function render() {
ctx.fillText('GAMES YOU ' + score.games[0] + ' — THEM ' + score.games[1], W / 2, H / 2); ctx.fillText('GAMES YOU ' + score.games[0] + ' — THEM ' + score.games[1], W / 2, H / 2);
if (G.eatenT > 0.7) { ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace'; ctx.fillText('TAP TO PLAY AGAIN', W / 2, H / 2 + 20); } if (G.eatenT > 0.7) { ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace'; ctx.fillText('TAP TO PLAY AGAIN', W / 2, H / 2 + 20); }
} }
if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten') { if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten' && !G.horde) {
ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace'; ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace';
const touch = matchMedia('(pointer: coarse)').matches; const touch = matchMedia('(pointer: coarse)').matches;
ctx.fillText(G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE') ctx.fillText(G.serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE')
+3
View File
@@ -31,6 +31,9 @@ export const G = {
splatT: 0, // time survived in splatter mode — drives the difficulty ramp splatT: 0, // time survived in splatter mode — drives the difficulty ramp
bombs: 0, // bomb-ball stock (max TUNE.bombMax); the next serve auto-consumes one bombs: 0, // bomb-ball stock (max TUNE.bombMax); the next serve auto-consumes one
killMeter: 0, // splatter kills toward the next bomb reward (every TUNE.bombEvery) killMeter: 0, // splatter kills toward the next bomb reward (every TUNE.bombEvery)
horde: null, // active horde wave {t, spawnT, mgT} — machine-gun racket, no serving
splatGames: 0, // splatter games you've won; the 2nd (and every one after) launches a horde
lastScoreT: -9, // idleT of the last score voice — rate-limits umpire during a horde
eatenT: 0, // time since you were eaten — gates the restart tap eatenT: 0, // time since you were eaten — gates the restart tap
missLog: [], // swing diagnostics ring buffer (tennis mode) — inspect via T.G.missLog missLog: [], // swing diagnostics ring buffer (tennis mode) — inspect via T.G.missLog
swingSnaps: null, // ball-approach samples during the live swing, feeds the miss log swingSnaps: null, // ball-approach samples during the live swing, feeds the miss log
+47 -9
View File
@@ -4,7 +4,7 @@
// (zombies <-> gameplay is a runtime-only import cycle, same pattern as input.) // (zombies <-> gameplay is a runtime-only import cycle, same pattern as input.)
import { TUNE, COURT } from './config.js'; import { TUNE, COURT } from './config.js';
import { G, P, ball, sendBoy } from './state.js'; import { G, P, ball, sendBoy, setupPoint } from './state.js';
import { splat, moan, pock, voice, boom } from './audio.js'; import { splat, moan, pock, voice, boom } from './audio.js';
import { splatPoint, cheer } from './gameplay.js'; import { splatPoint, cheer } from './gameplay.js';
@@ -12,6 +12,7 @@ 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 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 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 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}
let spawnT = 1.5, moanT = 3; let spawnT = 1.5, moanT = 3;
@@ -20,10 +21,9 @@ export function spawnZombie(x, z, variant) {
zombies.push({ x, z, v: TUNE.zSpeed * ramp * (0.8 + Math.random() * 0.4), walkT: Math.random() * 9, variant }); zombies.push({ x, z, v: TUNE.zSpeed * ramp * (0.8 + Math.random() * 0.4), walkT: Math.random() * 9, variant });
} }
function splatter(i) { function splatter(i, fanfare = true) {
const zb = zombies[i]; const zb = zombies[i];
zombies.splice(i, 1); zombies.splice(i, 1);
ball.kills++; // this shot's running streak
gore.push({ x: zb.x, z: zb.z, r: 0.45 + Math.random() * 0.35, a: 0.85 }); gore.push({ x: zb.x, z: zb.z, r: 0.45 + Math.random() * 0.35, a: 0.85 });
if (gore.length > 40) gore.shift(); 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 const pl = piles.find(p => Math.hypot(p.x - zb.x, p.z - zb.z) < 0.6); // stack up where kills cluster
@@ -38,7 +38,9 @@ function splatter(i) {
splat(); splat();
ball.vx *= TUNE.zPierce; ball.vz *= TUNE.zPierce; // keep enough pace to plow through the next ball.vx *= TUNE.zPierce; ball.vz *= TUNE.zPierce; // keep enough pace to plow through the next
sendBoy(zb.x, zb.z); // a boy sprints out to mop sendBoy(zb.x, zb.z); // a boy sprints out to mop
splatPoint('P'); splatPoint('P', !!G.horde); // horde kills rate-limit the umpire so he doesn't machine-gun scores
if (!fanfare) return; // machine-gun pellets splatter but skip the streak fanfare / bomb meter
ball.kills++; // this shot's running streak
// escalating multi-kill fanfare — one shot, ≥2 zombies // escalating multi-kill fanfare — one shot, ≥2 zombies
if (ball.kills === 2) { G.flash = { text: 'DOUBLE KILL', t: 0.9 }; cheer(1.6, 0.45); voice('double_kill', 0.4); } if (ball.kills === 2) { G.flash = { text: 'DOUBLE KILL', t: 0.9 }; cheer(1.6, 0.45); voice('double_kill', 0.4); }
else if (ball.kills === 3) { G.flash = { text: 'TRIPLE KILL', t: 0.9 }; cheer(2.2, 0.6); voice('triple_kill', 0.4); } else if (ball.kills === 3) { G.flash = { text: 'TRIPLE KILL', t: 0.9 }; cheer(2.2, 0.6); voice('triple_kill', 0.4); }
@@ -50,6 +52,29 @@ function splatter(i) {
} }
} }
// ---- horde wave: dense spawns + an auto-firing machine-gun racket (pellets, not the match ball)
function spawnHordeZombie() {
if (Math.random() < 0.5) spawnZombie((Math.random() * 2 - 1) * 5, -1.5 - Math.random(), Math.random() < 0.5 ? 'a' : 'b'); // far baseline
else spawnZombie(Math.random() < 0.5 ? -6 : 6, Math.random() * 8, Math.random() < 0.5 ? 'a' : 'b'); // in from the sides
}
function firePellet() {
const a = (Math.random() * 2 - 1) * 4 * Math.PI / 180, s = 26; // ±4° spread, straight up-court (-z)
pellets.push({ x: P.x, y: 0.9, z: P.z, vx: Math.sin(a) * s, vz: -Math.cos(a) * s, t: 1.2 });
pock(0.25, 1400); // rapid fire tick; if it clips on device, gate to every 2nd pellet
}
function endHorde() {
G.horde = null; pellets.length = 0;
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;
if ((h.spawnT -= dt) <= 0) { spawnHordeZombie(); h.spawnT = 1 / TUNE.hordeRate; }
if ((h.mgT -= dt) <= 0) { firePellet(); h.mgT = 1 / TUNE.mgRate; }
if (h.t <= 0) endHorde();
}
// bomb ball: everything inside bombRadius splatters at once (each kill still ++ball.kills so // 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. // the Phase-2 fanfare escalates), plus a crater — big stain, size-3 pile, extra particles, boom.
export function explode(x, z) { export function explode(x, z) {
@@ -72,10 +97,13 @@ export function updateZombies(dt) {
if (G.mode === 'splatter') { // transition spawns are scripted, not timed if (G.mode === 'splatter') { // transition spawns are scripted, not timed
G.splatT += dt; G.splatT += dt;
spawnT -= dt; if (G.horde) tickHorde(dt); // horde: dense spawns + machine gun, ignores zMax
if (spawnT <= 0 && zombies.length < TUNE.zMax) { else {
spawnZombie((Math.random() * 2 - 1) * 4.8, -1.5 - Math.random(), Math.random() < 0.5 ? 'a' : 'b'); spawnT -= dt;
spawnT = TUNE.zSpawnEvery; if (spawnT <= 0 && zombies.length < TUNE.zMax) {
spawnZombie((Math.random() * 2 - 1) * 4.8, -1.5 - Math.random(), Math.random() < 0.5 ? 'a' : 'b');
spawnT = TUNE.zSpawnEvery;
}
} }
} }
@@ -94,6 +122,7 @@ export function updateZombies(dt) {
splat(1); splat(1);
voice('game_set_massacre', 0.8); voice('game_set_massacre', 0.8);
G.mode = 'eaten'; ball.active = false; G.flash = null; // zombies keep shambling for ambience G.mode = 'eaten'; ball.active = false; G.flash = null; // zombies keep shambling for ambience
G.horde = null; pellets.length = 0; // the swarm stops; the eaten screen takes over
} }
if (ball.active && ball.lastHit === 'P' && ball.y < 1.9) { if (ball.active && ball.lastHit === 'P' && ball.y < 1.9) {
const d = Math.hypot(ball.x - zb.x, ball.z - zb.z); const d = Math.hypot(ball.x - zb.x, ball.z - zb.z);
@@ -108,7 +137,16 @@ export function updateZombies(dt) {
ball.vx *= 0.6; ball.vz *= 0.6; ball.lastHit = 'X'; // spent — one stagger per ball ball.vx *= 0.6; ball.vz *= 0.6; ball.lastHit = 'X'; // spent — one stagger per ball
} }
} }
if (zb.z >= COURT.L + 0.7) { zombies.splice(i, 1); if (G.mode !== 'eaten') splatPoint('O'); } // it reached your baseline if (zb.z >= COURT.L + 0.7) { zombies.splice(i, 1); if (G.mode !== 'eaten') splatPoint('O', !!G.horde); } // it reached your baseline
}
for (let i = pellets.length - 1; i >= 0; i--) { // machine-gun rounds: flat, fast, one-shot kills
const pe = pellets[i];
pe.t -= dt; pe.x += pe.vx * dt; pe.z += pe.vz * dt;
let hit = false;
for (let j = zombies.length - 1; j >= 0; j--)
if (Math.hypot(zombies[j].x - pe.x, zombies[j].z - pe.z) < 0.55) { splatter(j, false); hit = true; break; }
if (hit || pe.t <= 0) pellets.splice(i, 1);
} }
for (let i = parts.length - 1; i >= 0; i--) { for (let i = parts.length - 1; i >= 0; i--) {