The lob is the flick you already have, and a game stops being a death

Three from Oliver.

THE LOB. He picked option (c) from the written comparison — "no additional
gesture to learn" — and corrected the premise: the zombie does come to the
net (racketAI chases to the far service line, the classic CPU to z 9.5). So
the forward flick keeps its one meaning, "put it deep behind him", and the
game picks the trajectory that actually does that: a smash while he is back,
over his head once he is in. oppAtNet() is the whole switch, and it reads the
BODY, not O — in the match O is the invisible tennis body and only moves
between points, so it would have answered "on his mark" all rally.

lobArm 7.0 m is measured, not taste: a lob peaking at lobApex passes a man 7 m
from the net at almost exactly CONTACT.maxY, so the shot arms exactly as far
out as it can still clear him. The pace is derived from the height, so the same
arc comes off the baseline and off mid-court, and SHOT_DEPTH.lob is deeper than
deep — at the ordinary deep target the ball passed 2.05 m over him at the edge
of the band, which is inside his reach.

PACE, SEEN FROM THE STRIKER. "the opponent sees the ball really fast - the
player barely sees the speed increase." Measured: the ball is the same ball on
both phones, the camera is not — it compresses your own ball into the distance
and expands theirs as it arrives. Pace untouched; three channels added on the
striker's side: the ghost streak now reads pace (3-8 ghosts, was 3 at every
speed), the crack is pitched by it, and a fast ball kicks the camera. The
serve's own ladder is 49.2 plain / 50.8-52.4 hard, because serveArc's loft
margin takes 10% of the +9 back — widening that is his call, and PROJECT.md
records the knob and the risk.

THE DEATH MATCH. "each player has 8 blood points but the player explodes for
the winner already at 0:40." Two clocks: a game is four points, a life is
eight. A won game is only a game now (flash, serve, heal); the burst moved to
the death itself, on the dead message, where both phones agree. The summary
waits 1.4 s for the gore, because update() freezes the world on G.done and
would have hung a still frame of an explosion behind the trophy screen. And
bleedO tracks THEIR blood off the pos stream instead of our points-this-game,
which reset every game and ignored the heal.

4 new tests, 113 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-22 12:58:48 +02:00
1 parent 6e07dc74bb
commit 4f4534e272
9 files changed
+355 -38

No files matched your search

+96 -1
View File
@@ -491,6 +491,98 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu
`RETURNED`, `DOUBLE KILL`) is a footnote to something you already watched happen. Sharing the loud `RETURNED`, `DOUBLE KILL`) is a footnote to something you already watched happen. Sharing the loud
FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone. FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone.
## Session log: THE LOB, AND TWO THINGS THE MULTIPLAYER TEST FOUND (2026-08-22, same day)
### THE LOB IS THE FLICK YOU ALREADY HAVE — option (c), picked by Oliver after a written comparison
Oliver: *"when you're close to the net in a duel with another player there should be a gesture
allowing to make a ball flying high from below (lupfen)... this likely needs to be discussed."*
Three candidates were put to him — (a) slow drag vs fast flick, (b) a down-then-up hook, (c) context
arms it — and he picked (c): *"i think this is the easiest. no additional gesture to learn."* He also
corrected the premise: *"the opponent should come to the net too — in zombie tennis the zombie often
comes to the net"*, and he is right (racketAI chases up to `COURT.svcFar`, the classic CPU to z 9.5).
**The rule: a forward flick means "put it deep behind him", and the game picks the trajectory that
actually does that.** With him on his baseline that is the smash it has always been; with him in, it
is over his head. `oppAtNet()` in gameplay.js is the whole switch. Four things it rests on:
1. **It reads the BODY, not O.** In the duel and the match the opponent you play is the racket
zombie; O is the invisible tennis body the serve machinery is driven off and it only moves
between points, so asking O would have answered "on his mark" all rally, in the one mode Oliver
plays most. `farBody()` picks the 'r' zombie whenever there is one and is not netplay.
2. **`TUNE.lobArm` 7.0 m is not taste.** A lob peaking at `lobApex` passes a man 7 m from the net at
almost exactly `CONTACT.maxY` (2.3 m, the height above which nothing can be struck) — so the shot
arms EXACTLY as far out as it can still clear him. Measured, not guessed; the numbers are in the
suite. Move one of the three and re-measure the other two.
3. **The pace is DERIVED from the height, never chosen.** `vy = sqrt(2g(apex - y))` fixes the hang
time and the ground still to cover fixes the speed, so the same arc comes off the baseline (9.5
m/s) and off mid-court (5.8 m/s). A fixed speed was tried on paper first and is a fine lob from
one place and a flat drive from the other. It also carries NO topspin multiplier — the dive that
makes a hard flat ball land in would pull a lob into the net.
4. **`SHOT_DEPTH.lob` is DEEPER than `deep` (2.0 vs 3.0 m).** At the ordinary deep target the ball
passes only a metre or two behind the man it is going over, and at the far edge of the arm band
that was 2.05 m — inside his reach. The shot is defined by where it comes DOWN.
Measured, nominal (no scatter), from the baseline: over a man at the far service line it passes at
**3.25 m**, lands 2 m inside his baseline, 2.17 s of hang. Over one at z 9.5 (a real net-rusher):
5.4 m. The AI can and does run it down — a lob is not a free point in tennis either — but it pulls
him off the net, which is what the shot is for. The word `LOB` pops at the contact: (c) buys "no
gesture to learn" by letting context change what the flick means, and something has to say which
shot you just asked for.
### "THE OPPONENT SEES THE BALL REALLY FAST — THE PLAYER BARELY SEES THE SPEED INCREASE"
Measured first, because the report has two possible causes and only one of them is a bug.
**The ball is the same ball on both phones** — `netSendShot` copies vx/vy/vz/g verbatim and the
receiver mirrors them, no re-simulation. What differs is the CAMERA: it sits behind your baseline,
so your own ball's screen speed decays into the distance while the same shot expands toward the man
receiving it. That asymmetry cannot be fixed by changing pace.
The pace ladder, measured: **serve 49.2 plain / 50.8-52.4 with the hard flick** (the +9 m/s
`serveHard` asks for 56-58, and `serveArc`'s net-clearance loop takes 10% of it back in one cut);
**rally 19.2 + 14.4×timing, ×1.42 for a smash at flush timing** — so the smash is a real 42% when
you time it and nothing when you do not, and the hard SERVE is only ever ~3-6%.
Nothing about the pace was changed. Three feedback channels were added instead, all on the striker's
side, none of them touching a ball's physics:
- **The ghost streak now reads PACE.** It was three ghosts at every speed, so it said fast/not-fast
and nothing else; it is 3 to 8 now, sampled every frame, so a quick ball is both longer and more
spread out. A lob stays clean.
- **The crack is pitched by pace** (`strikeRate`, 0.93 to 1.09 — past a tenth the sample stops
sounding like the same racket).
- **A fast ball kicks the camera** (`strikeKick`, up to ~2.2 px for 0.12 s, YOUR racket only, and
never over a shake already running).
**Still open, and it is Oliver's call.** If he wants the hard serve to actually BE harder, the knob
is `serveArc`'s loft margin: it charges `0.06 + descent×0.04` (about 0.46 m of extra net clearance
on a fast serve) against a sampling error the sim does not really make any more — the net test in
`update()` already interpolates the crossing height, and semi-implicit Euler's real lag at the tape
measures ~2-17 cm. Cutting that margin gives the hard serve its full +9 back (58 vs 49, a difference
you could see). Not done unasked: a serve into the tape is worse than a slow one, and that margin
exists because balls were clipping it.
### THE DEATH MATCH: THE BURST IS THE DEATH, AND ONLY BLOOD IS
Oliver: *"each player has 8 blood points but the player explodes for the winner already at 0:40 —
the winner sees the zombie explode but the player on the other side is not dead yet. this needs to
be aligned."* Two clocks were running side by side: **a game is four points, a life is eight**, and
`matchGameWon` burst the friend's body on the game. Both were true on their own phone and they
disagreed with each other.
- **A won game is only a game now** (netplay branch of `matchGameWon`): the flash, the serve change
and the heal it pays, then the next point. No burst, no walk-on, no mop beat — there is no roster
body to walk back on in a death match anyway.
- **The burst moved to the death**, in `netMatchWon`, which fires on the `dead` message the dying
phone sends when its own blood runs out. Both sides are then agreeing about the same event.
- **The summary waits `TUNE.netWinBurst` (1.4 s) for the gore to fly.** `update()` freezes the whole
world on `G.done` — no particles — so setting it on the same frame as the burst hangs a still
frame of an explosion behind the trophy screen. Ticked in `tickMatchFall`, like every other beat
in beats.js that has to outlive the frame that started it.
- **The friend's bleeding tracks THEIR blood.** `bleedO` read points-won-this-game, which resets
every game and ignores the heal — a friend two points from death looked unhurt. Their hp now rides
the 10 Hz `pos` stream (one number; a dropped one corrects itself 100 ms later) and the flow reads
it. The old count survives as the fallback for a build that sends none.
## Session log: THE SERVICE RULES ARRIVE IN THE OTHER MODES (2026-08-22) ## Session log: THE SERVICE RULES ARRIVE IN THE OTHER MODES (2026-08-22)
Three notes from Oliver, two of them fixed here; the third (the lob) is under discussion in OPEN. Three notes from Oliver, two of them fixed here; the third (the lob) is under discussion in OPEN.
@@ -3941,7 +4033,10 @@ Nothing here is broken; these are known gaps and unverified risks.
from a tin). At 12 rounds/s a per-pellet sample will machine-gun the mixer, not just the wave. from a tin). At 12 rounds/s a per-pellet sample will machine-gun the mixer, not just the wave.
- **boss sounds** — footfall, wind-up, throw, wound, death. Three designs in `BOSS_ART`; decide - **boss sounds** — footfall, wind-up, throw, wound, death. Three designs in `BOSS_ART`; decide
whether they share one set or get one each before recording anything. whether they share one set or get one each before recording anything.
14. **THE LOB ("lupfen") — asked for 2026-08-22, UNDER DISCUSSION, do not build it yet.** Oliver: 14. ~~**THE LOB ("lupfen")**~~ SHIPPED 2026-08-22 as option (c) — see the session log. The rest of
this entry is the discussion it came out of, kept because the two rejected options are the
argument for the one that shipped.
**~~asked for 2026-08-22, UNDER DISCUSSION, do not build it yet.~~** Oliver:
*"when you're close to the net in a duel with another player there should be a gesture allowing *"when you're close to the net in a duel with another player there should be a gesture allowing
to make a ball flying high from below... I don't know how to implement this gesture wise — this to make a ball flying high from below... I don't know how to implement this gesture wise — this
likely needs to be discussed."* The shot itself is nearly free: `trySwing` already solves every likely needs to be discussed."* The shot itself is nearly free: `trySwing` already solves every
+1 -1
View File
@@ -664,6 +664,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
</div> </div>
</div> </div>
</div> </div>
<script type="module" src="src/main.js?v=306"></script> <script type="module" src="src/main.js?v=307"></script>
</body> </body>
</html> </html>
+35 -3
View File
@@ -499,6 +499,7 @@ export function tickMatchFall(dt) {
// 'over' beat (see the section note above). When the boy's time is up the next opponent walks // 'over' beat (see the section note above). When the boy's time is up the next opponent walks
// on with its own intro card, exactly as it used to on the kill frame. // on with its own intro card, exactly as it used to on the kill frame.
const d = G.match; const d = G.match;
if (d && d.winT > 0 && (d.winT -= dt) <= 0) { d.winT = 0; return netMatchDone(); }
if (d && d.mopT > 0 && (d.mopT -= dt) <= 0) { if (d && d.mopT > 0 && (d.mopT -= dt) <= 0) {
// Time is up — but THE COURT IS HIS UNTIL HE LEAVES IT (Oliver: "no playing as long as the // Time is up — but THE COURT IS HIS UNTIL HE LEAVES IT (Oliver: "no playing as long as the
// mopboy is on court"). He is ordered off (jobs zeroed, the half-pushed stain dropped) and // mopboy is on court"). He is ordered off (jobs zeroed, the half-pushed stain dropped) and
@@ -753,7 +754,22 @@ function matchGameWon(zb, banked = false) {
// NETPLAY: the human body has no fall art to play, so the game-winning point bursts him where // NETPLAY: the human body has no fall art to play, so the game-winning point bursts him where
// he stands (procedural gore is the one sanctioned fake) and the mop beat runs over the gap; // he stands (procedural gore is the one sanctioned fake) and the mop beat runs over the gap;
// matchOpponent's net branch un-hides him when the beat hands the court back. // matchOpponent's net branch un-hides him when the beat hands the court back.
if (G.net) { explode(O.x, O.z); G.hideO = true; } // NETPLAY DEATH MATCH: A GAME IS NOT A DEATH, AND ONLY BLOOD IS (Oliver, 2026-08-22: "each player
// has 8 blood points but the player explodes for the winner already at 0:40 — the winner sees the
// zombie explode but the player on the other side is not dead yet. this needs to be aligned").
// Two clocks were running: a GAME is four points, a life is eight, so the body burst on this
// phone while the friend was still standing on theirs with half a pool left. The burst moves to
// the only event both phones agree is death — their blood running out, said out loud over the
// wire (netSendDead -> netMatchWon). A won game keeps everything it is worth: the flash, the
// serve, and the heal it pays. Solo is untouched — there the body IS the scoreboard.
if (G.net) {
matchHealBeat();
d.ending = false; // nothing is walking on: play resumes here
matchReady();
G.interlude = { introT: 1.2 }; // a breath before the next serve, as ever
G.phase = 'over'; G.overT = 2.2;
return;
}
// Everything past the burst waits for it — the heal card, the boy, the walk-on clock. // Everything past the burst waits for it — the heal card, the boy, the walk-on clock.
d.afterFall = () => { d.afterFall = () => {
matchHealBeat(); matchHealBeat();
@@ -839,13 +855,29 @@ function cupWon() {
// NETPLAY'S WIN ENDING: the other phone said 'dead' — their player bled out, so this side is the // NETPLAY'S WIN ENDING: the other phone said 'dead' — their player bled out, so this side is the
// last one standing. The classic win's whole shape (held summary, gated tap), banked NOT-dead // last one standing. The classic win's whole shape (held summary, gated tap), banked NOT-dead
// through the same endRun as every match run; felled = the games that actually burst the body. // through the same endRun as every match run; felled = the games that actually burst the body.
// THE FRIEND'S BLOOD RAN OUT — the one event that ends a death match, and now the only one that
// bursts him. It arrives as a message from their phone (they bookkeep their own hp), which is why
// this is the honest place for the burst: both sides are agreeing about the same death.
export function netMatchWon() { export function netMatchWon() {
if (!G.match) return; if (!G.match || G.match.winT > 0) return; // already ending
const d = G.match; const d = G.match;
d.ending = false; d.mopT = 0; d.ending = false; d.mopT = 0;
G.interlude = false; G.matchFall = null; G.matchFall = null;
ball.active = false; G.flash = null; G.modal = null; G.card = null; ball.active = false; G.flash = null; G.modal = null; G.card = null;
explode(O.x, O.z); G.hideO = true;
cheer(4, 1); voice('unbelievable', 0.4); cheer(4, 1); voice('unbelievable', 0.4);
// ...AND THE SUMMARY WAITS FOR THE GORE. update() freezes the whole world on G.done — no
// particles, no parts — so setting it on the same frame as the burst would hang a still frame of
// an explosion behind the trophy screen. The beat is ticked in tickMatchFall, like every other
// beat in this file that has to outlive the frame that started it.
d.winT = TUNE.netWinBurst;
G.interlude = { introT: TUNE.netWinBurst + 1 }; // nothing gets served over a body coming apart
G.phase = 'over'; G.overT = TUNE.netWinBurst + 1;
}
function netMatchDone() {
const d = G.match;
G.interlude = false;
const games = score.games[0] + score.games[1]; const games = score.games[0] + score.games[1];
const b = endRun(score.games[0], games, G.splatT, false, MODE_MATCH); const b = endRun(score.games[0], games, G.splatT, false, MODE_MATCH);
G.best = b; G.newBest = b.newBest; G.best = b; G.newBest = b.newBest;
+17 -1
View File
@@ -74,6 +74,16 @@ export const TUNE = {
smashBoost: 0.42, // extra pace on a FORWARD flick (aim.az < 0), scaled by timing. It does not smashBoost: 0.42, // extra pace on a FORWARD flick (aim.az < 0), scaled by timing. It does not
// send the ball longer — solveShot still lands it on the same spot — it // send the ball longer — solveShot still lands it on the same spot — it
// gets there flatter and sooner, which is what a smash is. // gets there flatter and sooner, which is what a smash is.
// THE LOB ("lupfen", Oliver 2026-08-22). Same forward flick as the smash — the game picks which
// shot that gesture means from WHERE THE OTHER MAN IS STANDING, so there is no second gesture to
// learn. He is "in" when he is closer to the net than this, in metres:
lobArm: 7.0, // ...and 7.0 is not a taste number. A lob peaking at lobApex passes a man
// 7 m from the net at almost exactly CONTACT.maxY (2.3 m), so the shot
// arms EXACTLY where it can still go over him. Raise one, raise the other.
lobApex: 6.0, // m the lob peaks at, whatever the distance. The pace is DERIVED from this
// (see doHit): a fixed speed would be a fine lob from the baseline and a
// flat drive from mid-court, because the same speed over half the ground
// is half the hang time. Height is the shot; speed is what it costs.
aimError: 2.4, // m of scatter at quality 0 aimError: 2.4, // m of scatter at quality 0
aimTrust: 0.3, // an aimed rally shot TRUSTS its flick: the scatter may still push the aimTrust: 0.3, // an aimed rally shot TRUSTS its flick: the scatter may still push the
// ball further OUT (the chosen out-risk keeps its exact shape) but only // ball further OUT (the chosen out-risk keeps its exact shape) but only
@@ -587,6 +597,8 @@ export const TUNE = {
// ("still a LOT of unreachable balls"); now line-painting is the late ladder // ("still a LOT of unreachable balls"); now line-painting is the late ladder
matchWidePer: 0.25, // ...earning this much more width per opponent felled (softened with speedPer) matchWidePer: 0.25, // ...earning this much more width per opponent felled (softened with speedPer)
matchHeal: 2, // hp back for a game won (the duel's healPerDuel rate) matchHeal: 2, // hp back for a game won (the duel's healPerDuel rate)
netWinBurst: 1.4, // s the friend's burst plays before the win screen covers it. update() freezes
// the world on G.done, so this beat is the only thing that lets the gore fly.
matchBoomHold: 0.5, // s the game-winning body lies prone before it bursts — the fall must be SEEN matchBoomHold: 0.5, // s the game-winning body lies prone before it bursts — the fall must be SEEN
killGush: 3, // prone bleed-out during the kill fall: particles ATTEMPTED per frame (gushProne) killGush: 3, // prone bleed-out during the kill fall: particles ATTEMPTED per frame (gushProne)
// — the body visibly bleeds out on the ground before it bursts // — the body visibly bleeds out on the ground before it bursts
@@ -1397,7 +1409,11 @@ export const P_BOUNDS = { x: 6.5, zMin: COURT.netZ + 1.2, zMax: 26 };
export const TOSS = { y0: 1.8, vy: 4.6, dx: -0.44, oDx: 0.42 }; export const TOSS = { y0: 1.8, vy: 4.6, dx: -0.44, oDx: 0.42 };
// shot target depth from the receiving baseline, by aim (up = deep, none = mid, down = short) // shot target depth from the receiving baseline, by aim (up = deep, none = mid, down = short)
export const SHOT_DEPTH = { deep: 3.0, mid: 5.4, short: 8.6 }; // `lob` is DEEPER than deep on purpose: a lob that lands where an ordinary drive lands passes only
// a metre or two behind the man it is going over, and a ball that close to him is still inside his
// 2.3 m reach (measured — at SHOT_DEPTH.deep it cleared him by nothing at the far edge of the arm
// band). The shot is defined by where it comes DOWN, not by how high it goes.
export const SHOT_DEPTH = { deep: 3.0, mid: 5.4, short: 8.6, lob: 2.0 };
// how long swing/serve poses hold before returning to idle (s) // how long swing/serve poses hold before returning to idle (s)
export const POSE_TIME = 0.38; export const POSE_TIME = 0.38;
+57 -6
View File
@@ -16,6 +16,21 @@ import { netSendShot, netSendToss, netTick, netJudging, netSendPoint, netSendFau
// Launch ball from (x0,y0,z0) so it arrives at (tx, tz) with horizontal speed s under gravity g. // Launch ball from (x0,y0,z0) so it arrives at (tx, tz) with horizontal speed s under gravity g.
// `ty` is the height it arrives AT — ball-on-the-grass by default, but a splatter serve aims at a // `ty` is the height it arrives AT — ball-on-the-grass by default, but a splatter serve aims at a
// shambler's chest, and solving to the ground under one is what made the ball strike the ankles. // shambler's chest, and solving to the ground under one is what made the ball strike the ankles.
// The recorded racket sample, pitched by the pace of the ball that just left it — 20 m/s of rally
// dink at 0.93, a 52 m/s serve at 1.09. Deliberately narrow: past about a tenth either way the
// sample stops sounding like the same racket. Read off the LIVE ball, so it is one call site's
// worth of code for every strike in the game.
const strikeRate = () => 0.93 + Math.max(0, Math.min(0.16, (Math.hypot(ball.vx, ball.vz) - 20) / 200));
// ...and a fast ball KICKS THE CAMERA as it leaves. The third answer to the same report: the trail
// says how fast the ball is, the crack says how hard it was hit, and this says it in the one channel
// the striker cannot miss, because it moves the whole picture rather than something inside it.
// YOUR racket only (the far side's strike is something you watch — the same rule the haptic keeps),
// nothing under 26 m/s, and never over a shake that is already running: a bomb outranks a backhand.
const strikeKick = () => {
const p = Math.min(2.2, (Math.hypot(ball.vx, ball.vz) - 26) / 9);
if (p > 0 && !G.shake) G.shake = { t: 0.12, mag: p * 2.92 }; // mag*t/0.35 = the pixels it starts at
};
export function solveShot(x0, y0, z0, tx, tz, s, g = TUNE.gravity, ty = 0.08) { export function solveShot(x0, y0, z0, tx, tz, s, g = TUNE.gravity, ty = 0.08) {
const dx = tx - x0, dz = tz - z0, dist = Math.hypot(dx, dz), T = dist / s; const dx = tx - x0, dz = tz - z0, dist = Math.hypot(dx, dz), T = dist / s;
return { vx: dx / T, vz: dz / T, vy: (ty - y0 + 0.5 * g * T * T) / T }; return { vx: dx / T, vz: dz / T, vy: (ty - y0 + 0.5 * g * T * T) / T };
@@ -142,6 +157,15 @@ export const swayOf = aim => aim.mag === undefined ? aim.ax
// as flat rather than resetting the lesson it just failed to use. // as flat rather than resetting the lesson it just failed to use.
export const flatCount = (streak, aim) => (aim && Math.abs(swayOf(aim)) > 0.15 ? 0 : streak + 1); export const flatCount = (streak, aim) => (aim && Math.abs(swayOf(aim)) > 0.15 ? 0 : streak + 1);
// WHERE THE MAN ACROSS THE NET ACTUALLY IS — and it is not always O. In the duel and the match the
// opponent is the BODY: O is the invisible tennis body the serve machinery is driven off, and it
// only moves between points, so asking O where the opponent is would answer "on his mark" all
// rally. In classic and in netplay (both kinds) O is the opponent, drawn and driven.
const farBody = () => (!G.net && zombies.find(z => z.variant === 'r')) || O;
// HAS HE COME IN? The one question the lob is armed on — see TUNE.lobArm for why 7 m and not taste.
// Exported for the suite and for anything that ever wants to show the player that the shot is live.
export const oppAtNet = () => COURT.netZ - farBody().z < TUNE.lobArm;
function doHit(ch, quality, aim) { function doHit(ch, quality, aim) {
// aim: {ax: -1|0|1, az: -1|0|1} from held keys / flick at contact (az -1 = deep, 1 = short) // aim: {ax: -1|0|1, az: -1|0|1} from held keys / flick at contact (az -1 = deep, 1 = short)
const toFar = ch.side === 'P'; const toFar = ch.side === 'P';
@@ -156,7 +180,7 @@ function doHit(ch, quality, aim) {
ball.x = ch.x + ch.facing * 0.5; ball.x = ch.x + ch.facing * 0.5;
ball.z = ch.z + (toFar ? -0.3 : 0.3); ball.z = ch.z + (toFar ? -0.3 : 0.3);
const err = () => (Math.random() * 2 - 1) * TUNE.aimError * (1 - quality); const err = () => (Math.random() * 2 - 1) * TUNE.aimError * (1 - quality);
let tx, tz, aimedLane = false, sway = 0; let tx, tz, aimedLane = false, sway = 0, lob = false;
let s = TUNE.baseShotSpeed + quality * TUNE.qualityShotSpeed; let s = TUNE.baseShotSpeed + quality * TUNE.qualityShotSpeed;
const ft = G.mode !== 'tennis' && !G.interlude && ch.side === 'P' // the duel aims like tennis: lanes, not free-aim const ft = G.mode !== 'tennis' && !G.interlude && ch.side === 'P' // the duel aims like tennis: lanes, not free-aim
? (aim && aim.fx !== undefined ? flickTarget(ch, aim) : { zt: zombieTarget(aim) }) : null; ? (aim && aim.fx !== undefined ? flickTarget(ch, aim) : { zt: zombieTarget(aim) }) : null;
@@ -206,14 +230,30 @@ function doHit(ch, quality, aim) {
const bound = Math.max(-TUNE.flickWidth, Math.min(TUNE.flickWidth, ch.x)); const bound = Math.max(-TUNE.flickWidth, Math.min(TUNE.flickWidth, ch.x));
tx = sway < 0 ? Math.min(tx, bound) : Math.max(tx, bound); tx = sway < 0 ? Math.min(tx, bound) : Math.max(tx, bound);
} }
const tzBase = aim.az < 0 ? SHOT_DEPTH.deep : aim.az > 0 ? SHOT_DEPTH.short : SHOT_DEPTH.mid; // THE LOB IS THE SAME FLICK, READ AGAINST WHERE HE IS STANDING (Oliver 2026-08-22, "lupfen").
// Forward flick = "put it deep behind him", and when he has come to the net the trajectory that
// does that is over his head, not through him. No new gesture, nothing to learn, and it cannot
// be mis-fired — with him on his baseline the same flick is the smash it has always been.
lob = ch.side === 'P' && aim.az < 0 && oppAtNet();
const tzBase = lob ? SHOT_DEPTH.lob
: aim.az < 0 ? SHOT_DEPTH.deep : aim.az > 0 ? SHOT_DEPTH.short : SHOT_DEPTH.mid;
tz = (toFar ? tzBase : COURT.L - tzBase) + err() * 0.8; tz = (toFar ? tzBase : COURT.L - tzBase) + err() * 0.8;
// A FORWARD FLICK IS A SMASH. Up-screen is the far court, so flicking INTO the court asked for // A FORWARD FLICK IS A SMASH. Up-screen is the far court, so flicking INTO the court asked for
// depth and nothing else — the gesture had a direction with no consequence behind it. It buys // depth and nothing else — the gesture had a direction with no consequence behind it. It buys
// PACE now, scaled by timing like the width is. Note what it deliberately does not do: solveShot // PACE now, scaled by timing like the width is. Note what it deliberately does not do: solveShot
// still lands the ball on the same tz, so extra speed makes the shot flatter and earlier rather // still lands the ball on the same tz, so extra speed makes the shot flatter and earlier rather
// than longer. A smash you can hit out on a whim would just be the bug above wearing a hat. // than longer. A smash you can hit out on a whim would just be the bug above wearing a hat.
if (ch.side === 'P' && aim.az < 0) s *= 1 + TUNE.smashBoost * quality; if (ch.side === 'P' && aim.az < 0 && !lob) s *= 1 + TUNE.smashBoost * quality;
// PACE DERIVED FROM THE HEIGHT, not chosen: for the lob to peak at lobApex it must leave at
// vy = sqrt(2g(apex - y)), which fixes the hang time, and the hang time over the ground still
// to cover fixes the speed. Same arc from the baseline and from mid-court — a fixed speed
// would be a lob from one and a flat drive from the other. Timing still prices it: `tz` above
// already carries the same scatter every aimed ball does.
if (lob) {
const vy0 = Math.sqrt(2 * TUNE.gravity * Math.max(0.5, TUNE.lobApex - ball.y));
const hang = (vy0 + Math.sqrt(vy0 * vy0 + 2 * TUNE.gravity * Math.max(0, ball.y - 0.08))) / TUNE.gravity;
s = Math.hypot(tx - ball.x, tz - ball.z) / hang;
}
aimedLane = true; aimedLane = true;
} }
tx = Math.max(-5, Math.min(5, tx)); tx = Math.max(-5, Math.min(5, tx));
@@ -247,7 +287,9 @@ function doHit(ch, quality, aim) {
} }
// topspin: quality shots dive under extra effective gravity, which is what lets real // topspin: quality shots dive under extra effective gravity, which is what lets real
// pace arc over the net and still land in — the core tennis trade-off // pace arc over the net and still land in — the core tennis trade-off
const g = TUNE.gravity * (1 + quality * TUNE.spinFactor); // ...and a lob carries NO topspin: the dive multiplier is what makes a hard flat ball land in, and
// on a shot whose whole point is hang time it would just pull the arc down into the net.
const g = lob ? TUNE.gravity : TUNE.gravity * (1 + quality * TUNE.spinFactor);
let v = solveShot(ball.x, ball.y, ball.z, tx, tz, s, g); let v = solveShot(ball.x, ball.y, ball.z, tx, tz, s, g);
// low contact → trade pace for loft until the net is cleared; good timing gets a // low contact → trade pace for loft until the net is cleared; good timing gets a
// comfortable margin, sloppy timing keeps some tape risk. Same crossesNet guard as serveArc's, // comfortable margin, sloppy timing keeps some tape risk. Same crossesNet guard as serveArc's,
@@ -273,7 +315,11 @@ function doHit(ch, quality, aim) {
// (the landing ring that used to be stamped here confused testers — the umpire calls the ball, // (the landing ring that used to be stamped here confused testers — the umpire calls the ball,
// the court stays clean) // the court stays clean)
if (ch.side === 'P') G.aiReactT = 0; // CPU hasn't read the shot yet if (ch.side === 'P') G.aiReactT = 0; // CPU hasn't read the shot yet
sfx(ch.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true }); // recorded racket strike, per side // ...and the CRACK carries the pace (same report as the ball streak): one recorded sample, pitched
// by how fast the ball actually left. It is the striker's cue at the one moment he is looking at
// his own racket rather than at the far court.
sfx(ch.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true, rate: strikeRate() }); // recorded racket strike, per side
if (ch.side === 'P') strikeKick();
// CONTACT IS THE TICK. Only YOUR racket: the far side's strike is something you watch, not // CONTACT IS THE TICK. Only YOUR racket: the far side's strike is something you watch, not
// something your hand did, and buzzing for both halves of every rally is the numbing failure mode. // something your hand did, and buzzing for both halves of every rally is the numbing failure mode.
if (ch.side === 'P') haptic(quality > 0.999 ? 'medium' : 'light'); // a perfect hit is felt as one if (ch.side === 'P') haptic(quality > 0.999 ? 'medium' : 'light'); // a perfect hit is felt as one
@@ -282,6 +328,10 @@ function doHit(ch, quality, aim) {
const w = praiseWord(); // a rally hit IS the impact — the word pops right on the contact const w = praiseWord(); // a rally hit IS the impact — the word pops right on the contact
if (w) popText(ball.x, ball.y, ball.z, w); if (w) popText(ball.x, ball.y, ball.z, w);
} }
// ...and the lob NAMES ITSELF. (c) buys "no gesture to learn" by letting context change what the
// flick means, and the price of that is that nothing on screen says which shot you just asked
// for. One word at the contact, in the praise words' own slot, is what pays it back.
if (lob) popText(ball.x, ball.y + 0.3, ball.z, 'LOB');
ch.pose = 'swing'; ch.poseT = 0; ch.pose = 'swing'; ch.poseT = 0;
if (G.net && ch.side === 'P') netSendShot(); // netplay: the shot IS the sync unit if (G.net && ch.side === 'P') netSendShot(); // netplay: the shot IS the sync unit
} }
@@ -515,7 +565,8 @@ export function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
} }
} }
// (spendSlot already hands you back plain balls when the last one goes) // (spendSlot already hands you back plain balls when the last one goes)
G.phase = 'rally'; sfx(sv.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true }); // recorded serve strike, per side G.phase = 'rally'; sfx(sv.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true, rate: strikeRate() }); // recorded serve strike, per side
if (sv.side === 'P') strikeKick();
if (sv.side === 'P') haptic(perfect ? 'medium' : 'light'); // the strike, same tick as a rally hit if (sv.side === 'P') haptic(perfect ? 'medium' : 'light'); // the strike, same tick as a rally hit
sv.pose = 'serve'; sv.poseT = 0; sv.pose = 'serve'; sv.poseT = 0;
if (perfect && sv.side === 'P') { if (perfect && sv.side === 'P') {
+7 -1
View File
@@ -221,7 +221,13 @@ export function netTick(dt) {
posT = 0; posT = 0;
// the HOST's stream carries the scoreboard as well (netSync) — one authority, no new message // the HOST's stream carries the scoreboard as well (netSync) — one authority, no new message
// type, and it only rides the frames where the guest can actually act on it // type, and it only rides the frames where the guest can actually act on it
send({ t: 'pos', x: r3(P.x), z: r3(P.z), m: P.pose === 'run' ? 1 : 0, // ...and it carries MY BLOOD. Each phone bookkeeps its own hp (matchPoint), so without this the
// other side has to GUESS how close I am to death — it was reading its own points-this-game
// instead, which resets every game and ignores the heal, so the friend's body showed a wound
// state that had nothing to do with the blood left in it (Oliver, 2026-08-22: "the winner sees
// the zombie explode but the player on the other side is not dead yet"). One number on a stream
// that already exists, ten times a second: a dropped one corrects itself 100 ms later.
send({ t: 'pos', x: r3(P.x), z: r3(P.z), m: P.pose === 'run' ? 1 : 0, hp: G.hp,
...(n.side === 'A' && G.phase === 'pretoss' ? { srv: score.server, p: score.pts } : {}) }); ...(n.side === 'A' && G.phase === 'pretoss' ? { srv: score.server, p: score.pts } : {}) });
} }
const o = n.opp; const o = n.opp;
+19 -10
View File
@@ -2567,23 +2567,32 @@ export function render() {
const by = Math.abs(Math.sin(G.idleT * BOUNCE_IDLE.tempo)) * (mine ? bounceApex() : (oTour().oApex ?? BOUNCE_IDLE.oApex)); const by = Math.abs(Math.sin(G.idleT * BOUNCE_IDLE.tempo)) * (mine ? bounceApex() : (oTour().oApex ?? BOUNCE_IDLE.oApex));
drawBall(bx, by, sv.z); drawBall(bx, by, sv.z);
} }
// hot-ball ghost streak: a fast rally ball drags three fading ghosts behind it. It gated on // hot-ball ghost streak: a fast ball drags fading ghosts behind it. It gated on pow > 0.8 alone,
// pow > 0.8 alone, which in practice only the gore slingshot ever reached — tennis tap swings // which in practice only the gore slingshot ever reached — tennis tap swings live below it, so
// live below it, so both tennis modes played without the streak (Oliver's sidequest). PACE now // both tennis modes played without the streak (Oliver's sidequest). PACE now qualifies a ball
// qualifies a ball too: a flat serve or a clean winner streaks in every mode, a lob stays clean. // too: a flat serve or a clean winner streaks in every mode, a lob stays clean.
// ...AND THE STREAK IS HOW LONG THE BALL IS FAST (Oliver, 2026-08-22, after the multiplayer test:
// "when smashing the opponent sees the ball really fast — the player barely sees the speed
// increase"). It was three ghosts at every pace, so it said "fast/not fast" and nothing else —
// while the CAMERA works against the striker, compressing his own ball into the distance as the
// same shot expands toward the man receiving it. Length reads as pace on both phones now, and it
// is the striker's only cue that the ball he just hit is quick. Sampled every frame, so a fast
// ball's ghosts are ALSO further apart: pace shows as reach and as spacing at once.
const bspd = Math.hypot(ball.vx, ball.vz);
const hot = ball.active && G.phase === 'rally' && !ball.collected const hot = ball.active && G.phase === 'rally' && !ball.collected
&& ((ball.pow || 0) > 0.8 || Math.hypot(ball.vx, ball.vz) > TUNE.trailSpeed); && ((ball.pow || 0) > 0.8 || bspd > TUNE.trailSpeed);
if (hot) { ballHist.push({ x: ball.x, y: ball.y, z: ball.z }); if (ballHist.length > 7) ballHist.shift(); } const ghosts = Math.max(3, Math.min(8, Math.round((bspd - TUNE.trailSpeed) / 6)));
if (hot) { ballHist.push({ x: ball.x, y: ball.y, z: ball.z }); if (ballHist.length > 10) ballHist.shift(); }
else if (ballHist.length) ballHist.length = 0; else if (ballHist.length) ballHist.length = 0;
if (hot) { if (hot) {
for (let i = 0; i < 3; i++) { for (let i = 0; i < ghosts; i++) {
const h = ballHist[ballHist.length - 2 - i * 2]; if (!h) break; const h = ballHist[ballHist.length - 2 - i]; if (!h) break;
const p = proj(h.x, h.y, h.z); const p = proj(h.x, h.y, h.z);
// 0.26 was tuned against the gore mode's dark rot palette; on the bright tennis lawn it // 0.26 was tuned against the gore mode's dark rot palette; on the bright tennis lawn it
// vanished entirely — "i see no ball trails" (Oliver, in the tournament) // vanished entirely — "i see no ball trails" (Oliver, in the tournament)
ctx.globalAlpha = 0.38 - i * 0.11; ctx.globalAlpha = 0.4 * (1 - i / ghosts);
ctx.fillStyle = UI.ball; ctx.fillStyle = UI.ball;
ctx.beginPath(); ctx.arc(p.x, p.y, Math.max(1.5, p.s * 0.09 * (0.9 - i * 0.18)), 0, 7); ctx.fill(); ctx.beginPath(); ctx.arc(p.x, p.y, Math.max(1.5, p.s * 0.09 * (0.95 - 0.6 * i / ghosts)), 0, 7); ctx.fill();
} }
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
} }
+9 -1
View File
@@ -933,7 +933,15 @@ function gushProne(zb) {
// with the body. Same thin-drop discipline as bleed() — more EMISSIONS, never heavier drops. // 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]]; const O_BLEED = [[0.12, 1.55, 0.9], [-0.22, 1.2, 0.8], [0.16, 0.9, 0.7]];
function bleedO() { function bleedO() {
const k = Math.min(3, score.pts[0]); // points off them THIS game // 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; if (!k || G.hideO || parts.length > 120) return;
for (const w of O_BLEED.slice(0, k)) { for (const w of O_BLEED.slice(0, k)) {
if (Math.random() > 0.14 * k) continue; // ramps: one light nick → gushing at game point if (Math.random() > 0.14 * k) continue; // ramps: one light nick → gushing at game point
+114 -14
View File
@@ -89,7 +89,7 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' });
// ---- game modules (real code) ---------------------------------------------- // ---- game modules (real code) ----------------------------------------------
const state = await import('./src/state.js'); const state = await import('./src/state.js');
const { G, P, O, score, ball, setupPoint } = state; const { G, P, O, score, ball, setupPoint } = state;
const { update, forceZombies, startGore, splatPoint, restartRun, flickTarget, flatCount, swayOf, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe, cheer, inCourt } = await import('./src/gameplay.js'); const { update, forceZombies, startGore, splatPoint, restartRun, flickTarget, flatCount, swayOf, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe, cheer, inCourt, oppAtNet } = await import('./src/gameplay.js');
const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch, FALL_LEN, startNetVs, endNetVs } = await import('./src/beats.js'); const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch, FALL_LEN, startNetVs, endNetVs } = await import('./src/beats.js');
const { mirror, netMsg, netTest, netTick } = await import('./src/net.js'); const { mirror, netMsg, netTest, netTick } = await import('./src/net.js');
// the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a // the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a
@@ -99,7 +99,7 @@ const { zombies, spawnZombie, pellets, parts, explode } = await import('./src/zo
const { killPlayer } = await import('./src/zombies.js'); const { killPlayer } = await import('./src/zombies.js');
const { drillQueue, drillLegal, arrival, drillMult, DRILL_TYPES } = await import('./src/drill.js'); const { drillQueue, drillLegal, arrival, drillMult, DRILL_TYPES } = await import('./src/drill.js');
const cfg = await import('./src/config.js'); const cfg = await import('./src/config.js');
const { TUNE, bossArt, COURT, racketStage, matchWound } = cfg; const { TUNE, bossArt, COURT, CONTACT, racketStage, matchWound } = cfg;
const statsMod = await import('./src/stats.js'); const statsMod = await import('./src/stats.js');
const audio = await import('./src/audio.js'); const audio = await import('./src/audio.js');
const { hapticTest } = await import('./src/haptics.js'); const { hapticTest } = await import('./src/haptics.js');
@@ -735,6 +735,91 @@ test('a receiver stands BEHIND his baseline, on both sides of the net', () => {
assert.ok(P.z > COURT.L, '...with the server behind his own line'); assert.ok(P.z > COURT.L, '...with the server behind his own line');
}); });
test('THE LOB: the forward flick goes OVER him once he has come in', () => {
// Oliver, 2026-08-22: "when you're close to the net in a duel there should be a gesture allowing
// to make a ball flying high from below." Option (c): no new gesture — the flick that already
// means "deep" picks the trajectory that actually puts the ball deep, which with a man at the net
// is over his head. The contract: unchanged while he is back, unhittable while he is in.
const shot = (oz, swipe) => {
const rnd = Math.random; Math.random = () => 0.5; // no scatter: the NOMINAL arc
startMatch(); G.interlude.introT = 0; G.phase = 'rally';
const zb = oppOf(); zb.x = 0; zb.z = oz;
P.x = 0; P.z = 23; P.swingT = -1;
ball.active = true; ball.lastHit = 'O'; ball.bounces = 1;
ball.x = 0; ball.z = 22.6; ball.y = 1.0; ball.vx = 0; ball.vy = 0; ball.vz = 1; ball.g = TUNE.gravity;
G.swipe = swipe; keyPressed('Space');
for (let i = 0; i < 20 && ball.lastHit !== 'P'; i++) update(DT);
G.swipe = null; Math.random = rnd;
assert.equal(ball.lastHit, 'P', 'setup: he got a racket on it');
let y = ball.y, z = ball.z, vy = ball.vy, apex = y, over = null;
const g = ball.g, dt = 1 / 240;
for (let i = 0; i < 4000 && y > 0; i++) {
const z0 = z; vy -= g * dt; y += vy * dt; z += ball.vz * dt;
apex = Math.max(apex, y);
if (over === null && z0 > zb.z && z <= zb.z) over = y; // height as it passes him
}
return { s: Math.hypot(ball.vx, ball.vz), apex, over, land: z };
};
const UP = { ax: 0, az: -1, fx: 0, fy: -1, mag: 90 };
const back = shot(1.5, UP);
assert.ok(back.apex < 2.5, 'he is on his baseline: the forward flick is the DRIVE it always was, apex ' + back.apex.toFixed(1));
assert.ok(back.s > 19, '...at drive pace: ' + back.s.toFixed(1) + ' m/s');
// ...and once he steps in, the SAME gesture lifts it over him. Two positions: the far service
// line (as far back as the racket zombie's chase ever brings it) and the edge of the arm band.
for (const oz of [9.5, 5.5, COURT.netZ - TUNE.lobArm + 0.05]) {
const l = shot(oz, UP);
assert.ok(Math.abs(l.apex - TUNE.lobApex) < 0.2, 'it peaks at lobApex from anywhere: ' + l.apex.toFixed(1));
assert.ok(l.over > CONTACT.maxY, 'it passes him at ' + l.over.toFixed(2) + 'm, over his ' + CONTACT.maxY + 'm reach (z ' + oz + ')');
assert.ok(l.land > 0.3 && l.land < COURT.netZ, '...and comes down IN, at z ' + l.land.toFixed(1));
assert.ok(l.s < back.s * 0.6, '...off a much slower ball: ' + l.s.toFixed(1) + ' m/s');
}
// the sideways pass is untouched — only the FORWARD flick changes meaning
const pass = shot(5.5, { ax: 1, az: 0, fx: 1, fy: 0, mag: 90 });
assert.ok(pass.apex < 2.5 && pass.s > 19, 'a sideways flick with him at the net is still a drive');
});
test('PACE IS FELT: a fast ball kicks the camera, a slow one does not', () => {
// Oliver, 2026-08-22, after the multiplayer test: "when smashing (for example on serve) the
// opponent sees the ball really fast — the player barely sees the speed increase." The ball IS
// the same ball on both phones (net.js copies the velocities verbatim); what differs is the
// camera, which compresses your own outgoing ball into the distance and expands theirs as it
// comes at you. So the striker gets the pace in three channels he cannot miss: the ghost streak's
// LENGTH (render), the crack's pitch, and this.
restartRun(); G.classic = true;
score.server = 'P'; setupPoint();
ball.x = P.x - 0.44; ball.z = P.z; ball.y = serveApexY(); ball.vx = ball.vy = ball.vz = 0;
G.shake = null;
hitServe(P, 1, null, true); // a flush serve: ~49 m/s
assert.ok(G.shake && G.shake.mag > 0, 'a 49 m/s serve kicks: mag ' + (G.shake && G.shake.mag.toFixed(1)));
const hard = G.shake.mag;
// ...and a slow ball does not. Same call, no timing on it.
score.server = 'P'; setupPoint();
ball.x = P.x - 0.44; ball.z = P.z; ball.y = serveApexY(); ball.vx = ball.vy = ball.vz = 0;
G.shake = null;
hitServe(P, 0.05, null, false); // a badly mistimed one: ~24 m/s
assert.ok(Math.hypot(ball.vx, ball.vz) < 30, 'setup: that one left slowly');
assert.equal(G.shake, null, 'a slow serve does not kick the camera');
assert.ok(hard > 0, '(and the fast one did: ' + hard.toFixed(1) + ')');
});
test('THE LOB arms off the BODY, not off the invisible tennis opponent', () => {
// In the match O is the body the serve machinery is driven off and it only moves between points;
// the opponent you actually play is the racket zombie. Asking O would answer "on his mark" all
// rally, so the lob would arm at the wrong times in the one mode Oliver plays most.
startMatch();
const zb = oppOf();
O.z = 10.5; // O parked at the net, the body back on its mark
zb.z = 1.5;
assert.ok(!oppAtNet(), 'the BODY is back, so nothing is armed');
zb.z = 6.0;
assert.ok(oppAtNet(), '...and the body coming in arms it');
// classic tennis has no body: there O IS the opponent
restartRun(); G.classic = true;
O.z = 1.5; assert.ok(!oppAtNet(), 'classic: O back, not armed');
O.z = 9.5; assert.ok(oppAtNet(), 'classic: O in, armed');
});
test('the match brings its own legs: auto-run works even where autoAssist is 0', () => { test('the match brings its own legs: auto-run works even where autoAssist is 0', () => {
// The desktop pane (pointer: fine) has autoAssist 0, so the match played as a statue with a // The desktop pane (pointer: fine) has autoAssist 0, so the match played as a statue with a
// racket — the flight recorder showed a swing in every lost point and an x that never moved. // racket — the flight recorder showed a swing in every lost point and an x that never moved.
@@ -1725,25 +1810,35 @@ test('netplay death match: the host\'s pick rebuilds the court on pairing — fr
assert.equal(G.modal, null, 'no popup may eat the first remote serve'); assert.equal(G.modal, null, 'no popup may eat the first remote serve');
}); });
test('netplay death match: their lost points read as BLOOD, and the game bursts the body', () => { test('netplay death match: THE BURST IS THE DEATH — a won game is only a game', () => {
// Oliver, 2026-08-22: "each player has 8 blood points but the player explodes for the winner
// already at 0:40 — the winner sees the zombie explode but the player on the other side is not
// dead yet. this needs to be aligned." Two clocks: a game is four points, a life is eight.
freshNetDeath('A'); freshNetDeath('A');
netMsg({ t: 'pt', win: 'you' }); // their phone judged: my ball beat them netMsg({ t: 'pt', win: 'you' }); // their phone judged: my ball beat them
assert.equal(score.pts[0], 1, 'the point banks — bleedO rides this ramp'); assert.equal(score.pts[0], 1, 'the point banks');
assert.ok(!netTest.sent.some(m => m.t === 'pt'), 'applying a verdict does not echo one back'); assert.ok(!netTest.sent.some(m => m.t === 'pt'), 'applying a verdict does not echo one back');
const blood = G.hp; const blood = G.hp;
netMsg({ t: 'pt', win: 'me' }); // their phone judged: their ball beat me netMsg({ t: 'pt', win: 'me' }); // their phone judged: their ball beat me
assert.equal(G.hp, blood - 1, 'their point draws my blood'); assert.equal(G.hp, blood - 1, 'their point draws my blood');
score.pts = [3, 0]; // game point against them: the flow at full ramp // THE FLOW READS THEIR BLOOD, off the position stream — not our points-this-game
const before = parts.length; const onHim = () => parts.filter(q => Math.abs(q.x - O.x) < 1 && Math.abs(q.z - O.z) < 1).length;
for (let i = 0; i < 30; i++) update(DT); netMsg({ t: 'pos', x: 0, z: 2, m: 0, hp: G.hpMax }); // (their z 2 mirrors to O.z 21.77... )
assert.ok(parts.length > before, 'a deeply wounded friend bleeds dynamically (bleedO)'); O.x = 0; O.z = 2; // ...so park him by hand: this is bleedO, not the lerp
for (let i = 0; i < 40; i++) { update(DT); O.x = 0; O.z = 2; }
assert.equal(onHim(), 0, 'a friend at full blood does not bleed, whatever our score says');
netMsg({ t: 'pos', x: 0, z: 2, m: 0, hp: 1 });
for (let i = 0; i < 40; i++) { update(DT); O.x = 0; O.z = 2; }
assert.ok(onHim() > 0, '...and one a point from death bleeds hard');
score.pts = [3, 0];
netMsg({ t: 'pt', win: 'you' }); // the game-winning point netMsg({ t: 'pt', win: 'you' }); // the game-winning point
assert.equal(score.games[0], 1, 'game banked'); assert.equal(score.games[0], 1, 'game banked');
assert.deepEqual(score.pts, [0, 0], 'the blood ramp resets with the body'); assert.equal(G.hideO, false, 'A GAME IS NOT A DEATH: the friend is still standing');
assert.equal(G.hideO, true, 'the burst took the body'); assert.ok(!G.done, '...and the match is not over');
for (let i = 0; i < 60 * 25 && G.hideO; i++) update(DT); for (let i = 0; i < 60 * 6; i++) update(DT);
assert.equal(G.hideO, false, 'the mop beat hands the court back and the friend rises again'); assert.equal(G.hideO, false, 'still standing a beat later — no burst, no walk-on');
assert.equal(G.match.opp, 1, 'no roster climb — the same friend, every game'); assert.equal(G.match.opp, 1, 'no roster climb — the same friend, every game');
assert.ok(G.phase === 'pretoss' || G.phase === 'over', 'and the court is playable again');
}); });
test('netplay death match: the receiver judges — an untouched winner draws blood and crosses the wire', () => { test('netplay death match: the receiver judges — an untouched winner draws blood and crosses the wire', () => {
@@ -1765,9 +1860,14 @@ test('netplay death match: bleeding out is said on the wire, and the other phone
assert.ok(netTest.sent.some(m => m.t === 'dead'), 'the death is said out loud'); assert.ok(netTest.sent.some(m => m.t === 'dead'), 'the death is said out loud');
freshNetDeath('A'); // ...and the winner's phone, receiving it freshNetDeath('A'); // ...and the winner's phone, receiving it
score.games[0] = 3; score.games[0] = 3;
const before = parts.length;
netMsg({ t: 'dead' }); netMsg({ t: 'dead' });
assert.ok(G.done && G.done.won, 'the last one standing gets the win ending'); assert.equal(G.hideO, true, 'THE BURST IS THE DEATH: their blood ran out, so the body comes apart');
assert.equal(G.match.felled, 3, 'felled = the games that actually burst the body'); assert.ok(parts.length > before, '...and the gore is real');
assert.ok(!G.done, 'the summary waits — a frozen explosion behind a trophy screen is the bug');
for (let i = 0; i < 60 * 3 && !G.done; i++) update(DT);
assert.ok(G.done && G.done.won, 'the last one standing gets the win ending, after the burst');
assert.equal(G.match.felled, 3, 'felled = the games won on the way');
}); });
test('death match (solo): the game-winning body bleeds out on the ground before it bursts', () => { test('death match (solo): the game-winning body bleeds out on the ground before it bursts', () => {