A perfect serve is a cut ball: it hooks from kill to kill

Slice is not a tin and not a rack slot. It is what a perfect serve DOES —
the toss ritual's 45 ms window, or a pull that stops inside perfectBand.
Every other weapon is handed to you; this one is a technique, and it hands
the plain ball a real route to the DOUBLE/TRIPLE KILL ladder that until now
only a bomb or a lucky line-up could reach. Banking your specials stops
meaning playing worse in the meantime.

Only a kill hooks, so a wound ends the chain and no brute, steward or boss
ever extends one. It is a crowd skill shot, which is the half of the arsenal
the tins do not cover.

The pull had to learn a third state to say this. Quality SATURATES at
pullFull, so an overpull and a perfect pull drew an identical band: the game
was asking you to hit a window it never showed you. The band now goes chalk
white inside it and the mark on the target grows a second ring — the fill
says where the ball lands, the ring says it will not stop there.

The chain path is deliberately NOT previewed. It is a scallop laid over the
exact patch of court you need to read, it solves the shot before you take
it, and the bodies walk — any chain drawn at pull time is a lie by the time
the ball arrives, which is the one thing the band exists not to be.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-09 13:44:17 +02:00
1 parent 655b86d27f
commit 99d84b2c9d
4 files changed
+91 -6

No files matched your search

+12
View File
@@ -237,6 +237,18 @@ export const TUNE = {
meleeCrowdR: 3.4, // m — the radius that counts as "on top of you" for that scaling (> meleeRange, meleeCrowdR: 3.4, // m — the radius that counts as "on top of you" for that scaling (> meleeRange,
// so the swish speeds up as they close in, not only once they are already on you) // so the swish speeds up as they close in, not only once they are already on you)
zPierce: 0.75, // pace a ball keeps after a splatter — hot balls plow through lined-up pairs zPierce: 0.75, // pace a ball keeps after a splatter — hot balls plow through lined-up pairs
// ---- SLICE. Not a tin and not a rack slot: it is what a PERFECT serve DOES. Cut the ball and it
// hooks off each body it kills onto the next one instead of plowing straight on. Every other
// weapon is something you are handed; this one is a technique, which is why it is earned on the
// timing window (the toss ritual's 45 ms, or a pull that STOPS inside perfectBand) rather than
// won out of a barrel. Only a KILL hooks — a wound ends the chain — so it is a crowd skill shot
// that does nothing to a brute, a steward or a boss, and it hands the plain ball a real route to
// the DOUBLE/TRIPLE KILL ladder that until now only a bomb or a lucky line-up could reach.
sliceHops: 2, // extra bodies one perfect serve hooks to. 2 => TRIPLE KILL is the ceiling
sliceR: 6.5, // m the hook reaches for the next body. Wider reads as the game aiming for you;
// much narrower and a spread-out crowd never chains at all
sliceTurn: 11, // rad/s the ball swings onto the next one, and THE knob for how it looks: past
// ~30 it snaps like a ricochet, under ~6 it sails wide before finishing the turn
// The schedule calls the DUEL, not the tin: weapons are only ever won, but a duel comes round // The schedule calls the DUEL, not the tin: weapons are only ever won, but a duel comes round
// on its own so there is always a chance at one. Fires off the next hand-cleared wave. // on its own so there is always a chance at one. Fires off the next hand-cleared wave.
duelFirst: 2, // game after which the first scheduled sudden-death duel is called duelFirst: 2, // game after which the first scheduled sudden-death duel is called
+35 -4
View File
@@ -161,6 +161,7 @@ function doHit(ch, quality, aim) {
ball.pow = quality; // carried pace: zombies splatter only above zSplatQ ball.pow = quality; // carried pace: zombies splatter only above zSplatQ
ball.kills = 0; // fresh shot: multi-kill streak restarts ball.kills = 0; // fresh shot: multi-kill streak restarts
ball.bomb = false; ball.flame = false; ball.wid = 'ball'; // only a consuming serve arms these; any other hit clears them ball.bomb = false; ball.flame = false; ball.wid = 'ball'; // only a consuming serve arms these; any other hit clears them
ball.chain = 0; ball.hook = null; // ...and the slice dies with the shot that earned it
ball.praise = null; // a re-hit is a new shot: a stale armed word must not pop on ITS impact ball.praise = null; // a re-hit is a new shot: a stale armed word must not pop on ITS impact
if (ch.side === 'P') { // shot marker: where this will land — red means it's going out if (ch.side === 'P') { // shot marker: where this will land — red means it's going out
// TENNIS ONLY. It answers "did that land in?", and splatter does not ask: `out` could never be // TENNIS ONLY. It answers "did that land in?", and splatter does not ask: `out` could never be
@@ -262,15 +263,20 @@ export function previewServe(aim) {
const st = splatServeTarget(P, aim, s, c.x, c.z); const st = splatServeTarget(P, aim, s, c.x, c.z);
if (!st) return null; if (!st) return null;
const { v, g } = serveArc(c.x, c.y, c.z, st.tx, st.tz, q, st.ty); const { v, g } = serveArc(c.x, c.y, c.z, st.tx, st.tz, q, st.ty);
return shotBand(c.x, c.y, c.z, v, g, st.tx, st.tz, st.ty, !!st.ft.zt, q); // ...and WHETHER THIS ONE SLICES. The perfect window was invisible until now: quality saturates
// at pullFull, so an overpull and a perfect pull drew an identical band and you were being asked
// to hit a target the game never showed you. Now the band goes chalk-white inside it. Plain balls
// only, because only a plain ball hooks (see hitServe).
return shotBand(c.x, c.y, c.z, v, g, st.tx, st.tz, st.ty, !!st.ft.zt, q,
pullPerfect(aim.mag) && G.weapon === 0 && !G.temp);
} }
// The drawable form of a shot: everything render needs and nothing that would pin a zombie object // The drawable form of a shot: everything render needs and nothing that would pin a zombie object
// alive (`zt` is a flag, not the body — the trail below outlives whatever it was aimed at). `ty` is // alive (`zt` is a flag, not the body — the trail below outlives whatever it was aimed at). `ty` is
// the arrival height, so the mark render paints sits exactly where the ball is going. // the arrival height, so the mark render paints sits exactly where the ball is going.
function shotBand(x0, y0, z0, v, g, tx, tz, ty, zt, q) { function shotBand(x0, y0, z0, v, g, tx, tz, ty, zt, q, pf = false) {
const T = Math.hypot(tx - x0, tz - z0) / (Math.hypot(v.vx, v.vz) || 1); // flight time to target const T = Math.hypot(tx - x0, tz - z0) / (Math.hypot(v.vx, v.vz) || 1); // flight time to target
return { x0, y0, z0, v, g, tx, tz, ty, zt, q, f: qualityFrac(q), T, t: 0 }; return { x0, y0, z0, v, g, tx, tz, ty, zt, q, pf, f: qualityFrac(q), T, t: 0 };
} }
// `perfect` is passed SEPARATELY from `quality` because the two serves earn it differently: the toss // `perfect` is passed SEPARATELY from `quality` because the two serves earn it differently: the toss
@@ -295,7 +301,8 @@ function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
// strobe — band, gone, band, gone. Instead the arc stays and the ball eats it from behind as it // strobe — band, gone, band, gone. Instead the arc stays and the ball eats it from behind as it
// flies, so the aim resolves into the shot rather than blinking out of existence. This is the // flies, so the aim resolves into the shot rather than blinking out of existence. This is the
// ACTUAL launch, error included, so the ball tracks the band it is consuming exactly. // ACTUAL launch, error included, so the ball tracks the band it is consuming exactly.
if (st) G.trail = shotBand(ball.x, ball.y, ball.z, v, g, boxX, tzs, st.ty, !!st.ft.zt, quality); if (st) G.trail = shotBand(ball.x, ball.y, ball.z, v, g, boxX, tzs, st.ty, !!st.ft.zt, quality,
perfect && G.mode === 'splatter' && !G.interlude && G.weapon === 0 && !G.temp);
ball.g = g; ball.g = g;
ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz; ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz;
ball.lastHit = sv.side; ball.bounces = 0; ball.lastHit = sv.side; ball.bounces = 0;
@@ -311,6 +318,11 @@ function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
const wsel = G.mode === 'splatter' && !G.interlude ? G.weapon : 0; const wsel = G.mode === 'splatter' && !G.interlude ? G.weapon : 0;
const wid = G.interlude || (wsel === 0 && !G.temp) ? 'ball' : slotId(wsel), armed = slotStock(wsel) > 0; const wid = G.interlude || (wsel === 0 && !G.temp) ? 'ball' : slotId(wsel), armed = slotStock(wsel) > 0;
ball.wid = armed ? wid : 'ball'; // an unstocked tin throws a plain ball — file the kills that way ball.wid = armed ? wid : 'ball'; // an unstocked tin throws a plain ball — file the kills that way
// THE SLICE. A perfect serve with a PLAIN ball is cut: it hooks body to body (see splatter).
// Plain only — a bomb, a flame ball and a laser already have an identity, and stacking a hook
// on top of one would read as the tin misbehaving rather than as something you did.
ball.chain = perfect && ball.wid === 'ball' ? TUNE.sliceHops : 0;
ball.hook = null;
// THE LASER fires instead of the ball, down the lane this serve was aimed at. Everything else // THE LASER fires instead of the ball, down the lane this serve was aimed at. Everything else
// about the gesture is untouched — the pull, the aim, the release — which is the whole point of it. // about the gesture is untouched — the pull, the aim, the release — which is the whole point of it.
if (wid === 'laser' && armed) { if (wid === 'laser' && armed) {
@@ -1561,6 +1573,25 @@ export function update(dt) {
t: 0.5 + Math.random() * 0.4, smoke: true }); t: 0.5 + Math.random() * 0.4, smoke: true });
} }
ball.vy -= (ball.g || TUNE.gravity) * dt; ball.vy -= (ball.g || TUNE.gravity) * dt;
// THE HOOK. splatter() points a sliced ball at its next body; this is the swing onto it, and it
// is the whole look of the weapon — a ball that BENDS through a crowd instead of ricocheting off
// it at an angle. It flies flat while hooking (a hop of a few metres would otherwise drop half a
// metre and pass under the next chest), and it sheds a one-pixel chalk thread so the curve is
// still on screen a beat after the ball has gone. The thread is the beacon's `fine` particle:
// 1 px, so the ball stays the bright thing and the line reads as the cut, not as a second ball.
if (ball.hook) {
const dx = ball.hook.x - ball.x, dz = ball.hook.z - ball.z;
if (dx * dx + dz * dz < 0.09) ball.hook = null; // arrived (or it moved) — gravity has it back
else {
const sp = Math.hypot(ball.vx, ball.vz) || 1;
const cur = Math.atan2(ball.vx, ball.vz), want = Math.atan2(dx, dz);
const da = ((want - cur + Math.PI * 3) % (Math.PI * 2)) - Math.PI; // shortest way round
const a = cur + Math.sign(da) * Math.min(Math.abs(da), TUNE.sliceTurn * dt);
ball.vx = Math.sin(a) * sp; ball.vz = Math.cos(a) * sp; ball.vy = 0;
parts.push({ x: ball.x, y: ball.y, z: ball.z, vx: 0, vy: 0, vz: 0,
t: 0.28, fine: true, spark: '#f2f2e6' });
}
}
const oldZ = ball.z; const oldZ = ball.z;
ball.x += ball.vx * dt; ball.y += ball.vy * dt; ball.z += ball.vz * dt; ball.x += ball.vx * dt; ball.y += ball.vy * dt; ball.z += ball.vz * dt;
// net: plane at netZ, only during rally flight // net: plane at netZ, only during rally flight
+15 -2
View File
@@ -744,7 +744,12 @@ function drawShotBand(a, u0, flying) {
// of tapering, so the gesture SHOWS you the wedge before you commit to it. Oliver asked for the // of tapering, so the gesture SHOWS you the wedge before you commit to it. Oliver asked for the
// band itself to communicate the shape of the shot. // band itself to communicate the shape of the shot.
const beamW = WEAPONS[slotId(G.weapon)]?.beam ? 1 : 0; const beamW = WEAPONS[slotId(G.weapon)]?.beam ? 1 : 0;
// CHALK WHITE = this one is SLICED. The band has always had one threshold (kills / only staggers)
// and the perfect window sat inside the killing half, invisible: quality saturates at pullFull, so
// an overpull drew exactly the band a perfect pull did. White is the third state, and the only
// place white appears on the court — nothing else in the palette is near it.
const hex = beamW ? WEAPONS[slotId(G.weapon)].col const hex = beamW ? WEAPONS[slotId(G.weapon)].col
: a.pf ? '#f2f2e6'
: a.q >= TUNE.zSplatQ ? (a.zt ? UI.hot : UI.warm) : '#9db4d0'; : a.q >= TUNE.zSplatQ ? (a.zt ? UI.hot : UI.warm) : '#9db4d0';
// the slice of arc to draw: everything while aiming, a short streak behind the ball in flight // the slice of arc to draw: everything while aiming, a short streak behind the ball in flight
const lo = flying ? Math.max(0, u0 - TAIL) : 0; const lo = flying ? Math.max(0, u0 - TAIL) : 0;
@@ -965,7 +970,7 @@ function drawBeamCuts(dirX, dirZ, ox, oz, L) {
function drawAimMark(a, u0, flying) { function drawAimMark(a, u0, flying) {
const left = 1 - u0; const left = 1 - u0;
if (left <= 0.01) return; if (left <= 0.01) return;
const col = a.q >= TUNE.zSplatQ ? (a.zt ? UI.hot : UI.warm) : '#9db4d0'; const col = a.pf ? '#f2f2e6' : a.q >= TUNE.zSplatQ ? (a.zt ? UI.hot : UI.warm) : '#9db4d0';
const onBody = !!a.zt; const onBody = !!a.zt;
const m = proj(a.tx, a.ty, a.tz); // a.ty is where the ball ARRIVES — mark and impact are one point const m = proj(a.tx, a.ty, a.tz); // a.ty is where the ball ARRIVES — mark and impact are one point
// NOTHING BEHIND THE CAMERA DRAWS. proj's scale is camF/(camZ - z), so a point at or past the // NOTHING BEHIND THE CAMERA DRAWS. proj's scale is camF/(camZ - z), so a point at or past the
@@ -982,7 +987,15 @@ function drawAimMark(a, u0, flying) {
if (onBody) ctx.ellipse(m.x, m.y, m.s * 0.26, m.s * 0.38, 0, 0, 7); // upright, over the torso if (onBody) ctx.ellipse(m.x, m.y, m.s * 0.26, m.s * 0.38, 0, 0, 7); // upright, over the torso
else ctx.ellipse(m.x, m.y, m.s * 0.30, m.s * 0.12, 0, 0, 7); // flat, lying on the grass else ctx.ellipse(m.x, m.y, m.s * 0.30, m.s * 0.12, 0, 0, 7); // flat, lying on the grass
ctx.fill(); ctx.fill();
ctx.globalAlpha = 1; // ...and a SECOND ring outside it when the shot is sliced: the fill says where the ball lands,
// the ring says it will not stop there. Drawn on the mark rather than as a third HUD element
// because your eye is already on this body — that is what you are aiming at.
if (a.pf && onBody) {
ctx.globalAlpha = 0.75 * (flying ? left * left : 1);
ctx.strokeStyle = col; ctx.lineWidth = Math.max(1, m.s * 0.02);
ctx.beginPath(); ctx.ellipse(m.x, m.y, m.s * 0.36, m.s * 0.50, 0, 0, 7); ctx.stroke();
}
ctx.globalAlpha = 1; ctx.lineWidth = 1;
} }
function drawRing(x, z, color, k = 1) { // k: radius scale — the landing ring contracts on time-to-touchdown function drawRing(x, z, color, k = 1) { // k: radius scale — the landing ring contracts on time-to-touchdown
+29
View File
@@ -630,8 +630,25 @@ export function meleeSwipe(x, z, range) {
function splatter(i, fanfare = true) { // the MAIN ball hit a zombie 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 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); killZombie(i, !!G.horde);
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
// SLICE: hook 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 gets
// here, so a wound ends the chain on its own and no brute, steward or boss ever extends one.
if (ball.chain > 0) {
const nx = nearestTo(zx, zz, TUNE.sliceR);
if (nx) {
ball.chain--;
const k = sp0 / (Math.hypot(ball.vx, ball.vz) || 1);
ball.vx *= k; ball.vz *= k;
ball.hook = { x: nx.x, z: nx.z }; // a POINT, not the body: the hook must not pin a corpse alive
G.trail = null; // the fired band is the parabola this ball has just STOPPED flying — leaving
// it up draws a streak peeling away from the ball, which is the one thing
// the band exists to avoid. From here the chalk thread is the trail.
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 if (!fanfare) return; // machine-gun pellets splatter but skip the streak fanfare / bomb meter
ball.kills++; // this shot's running streak ball.kills++; // this shot's running streak
// splat micro-freeze: every ball kill stutters the world a touch, and the stutter grows with the // splat micro-freeze: every ball kill stutters the world a touch, and the stutter grows with the
@@ -694,6 +711,18 @@ export function firePellet(w = WEAPONS.gun) {
else if (++mgTick % 2 === 0) pock(0.25, w.ignite ? 300 : 1400); else if (++mgTick % 2 === 0) pock(0.25, w.ignite ? 300 : 1400);
} }
const HUES = ['#ff6b6b', '#ffd166', '#7bffa0', '#6bd5ff', '#c08bff', '#ff8bd5']; const HUES = ['#ff6b6b', '#ffd166', '#7bffa0', '#6bd5ff', '#c08bff', '#ff8bd5'];
// the next body a slice hooks onto: nearest live thing to the kill it just made, in any direction —
// a cut ball can come BACK down-court, and the shot that curls round into something behind the body
// you aimed at is the one worth earning. Furniture and barrels are not bodies and never a target.
function nearestTo(x, z, maxD) {
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) { 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 // the best thing up-court to lob at: nearest body ahead of you, ignoring furniture you can walk past
function nearestAhead() { function nearestAhead() {
let best = null, bd = 1e9; let best = null, bd = 1e9;