The friend across the net is no shambler: he just bleeds, and then he bursts

Two retest findings, one root: the death match borrowed the roster's
body. Gone — nothing spawns, no walk-on card, the living opponent is the
whole picture. His damage reads as blood (bleedO: an anchor per point
taken this game, love to game point, thin drops as ever), the game
point bursts him where he stands, and IT RISES AGAIN brings him back
clean. The heal beat became a card in netplay — a modal freezes this
world while the other phone's keeps moving.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-14 18:10:50 +02:00
1 parent c0b59eee90
commit 4c47295a73
6 files changed
+95 -68

No files matched your search

+17 -13
View File
@@ -456,19 +456,23 @@ creating the room could decide whether it is classic tennis or death match.. in
wounded opponent would show increased dynamic bleeding before exploding."* The VS second step is
three chips now (HOST — CLASSIC TENNIS / HOST — DEATH MATCH / JOIN WITH A CODE); the host's pick
rides the room (`?death=1` at creation, carried in the relay's start message), so the joiner never
answers a question. On pairing BOTH phones rebuild the court into the zombie-tennis match — the
same verdicts drive `resolveInterlude`/`matchPoint` instead of `pointTo`, so wounds, blood, the
fall, the mop beat and the burst all come along unmodified. On each phone YOUR OPPONENT IS THE
ZOMBIE: their lost points tear its wound stages (your win = their body comes apart), their won
points draw your blood, and bleeding out sends `dead` — the other phone banks a NOT-dead match run
and holds the win screen. Wired details: the same friend walks back on after each burst (`d.opp`
frozen — the roster climb would hand them a new face; returns get an IT RISES AGAIN flash, not a
repeated name card), racketAI and the early-decide are gated off (`!G.net` — you cannot predict a
human's legs), match balls already passed through the body so no contact work was needed, and
`gushProne()` in zombies.js is the asked-for bleed-out: LOW anchors around the corpse during the
kill fall's prone hold (bleed() stays suppressed there — its standing-height anchors are the
"blood fountains midair" bug), `TUNE.killGush` attempts per frame until the burst. #netd hosts a
death room from a URL. 80 tests; the wound/blood/burst loop verified across two panes.
answers a question. On pairing BOTH phones rebuild the court into the match machinery — the same
verdicts drive `resolveInterlude`/`matchPoint` instead of `pointTo`, so blood, the mop beat and
the scoring come along unmodified. **THE OPPONENT IS THE FRIEND, NOT A ROSTER BODY** (his retest:
*"the opponent reads as zombie — it should be the normal opponent"*, and no SHAMBLER teaser
card): nothing spawns, the living O carries the whole picture, and since the human sprite has no
wound skins, **the score reads on him as BLOOD** — `bleedO()` in zombies.js opens an anchor per
point taken off him this game and thickens the flow, love → game point, thin drops throughout
(the settled rule: more emissions, never heavier drops). The game-winning point BURSTS him where
he stands (`explode` — the human has no fall art, and procedural gore is the sanctioned fake),
the mop beat runs over the gap, and IT RISES AGAIN returns him with the ramp reset. Their won
points draw your blood; bleeding out sends `dead` — the other phone banks a NOT-dead match run
and holds the win screen. Wired details: `d.opp` frozen (no roster climb — same friend every
game), racketAI / the early-decide / the empty-court-regicide check all gated `!G.net`, the heal
beat is a CARD in netplay (the modal froze this world while the other phone's kept moving), and
`gushProne()` gives the SOLO deathmatch's kill fall the same bleed-out from prone-height anchors
(bleed() stays suppressed there — standing anchors are the "blood fountains midair" bug). #netd
hosts a death room from a URL. 79 tests; the blood-ramp/burst/return loop verified across panes.
## Session log: the far court, without spending the lens (2026-08-13)
+1 -1
View File
@@ -494,6 +494,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
</div>
</div>
</div>
<script type="module" src="src/main.js?v=255"></script>
<script type="module" src="src/main.js?v=256"></script>
</body>
</html>
+29 -13
View File
@@ -575,6 +575,17 @@ function tickMatchServe(dt) {
}
function matchOpponent() {
// NETPLAY: THE OPPONENT IS THE FRIEND, NOT A ROSTER BODY (Oliver: "it should be the normal
// opponent"). Nothing spawns, nobody is renamed, no walk-on card — the living O carries the
// whole picture and the score reads on it as BLOOD (bleedO in zombies.js), because the human
// sprite has no wound skins to climb. This beat only returns the body after a burst and
// re-arms the intro bar.
if (G.net) {
G.hideO = false; // the burst took him; the walk-on returns him
G.flash = { text: 'IT RISES AGAIN.', t: 1.8 };
G.interlude = { introT: 1.6 };
return;
}
spawnRacketZombie();
const zb = zombies.find(z => z.variant === 'r');
zb.dueled = true; // wakes racketAI, same as a fresh duel
@@ -587,15 +598,9 @@ function matchOpponent() {
// 4.0 s: at 2.4 the head + name + punchline were gone before they were read ("the intro card
// of the player should last longer"). In the cup the round names itself on the third line.
const cup = G.match.cup;
// netplay: the same body keeps coming back, and the identical name card twice running reads as
// a stuck HUD (the praise-word lesson). The first walk-on introduces; every return is a flash.
if (G.net && G.match.introduced) G.flash = { text: 'IT RISES AGAIN.', t: 1.8 };
else {
G.match.introduced = true;
G.card = { title: zb.foe.name, sub1: zb.foe.line, color: UI.hot, t: 4.0, head: zb.fam,
sub2: cup ? CUP_ROUNDS[cup.round].name + (CUP_ROUNDS[cup.round].need > 1
? ' · FIRST TO ' + CUP_ROUNDS[cup.round].need + ' GAMES' : '') : undefined };
}
G.card = { title: zb.foe.name, sub1: zb.foe.line, color: UI.hot, t: 4.0, head: zb.fam,
sub2: cup ? CUP_ROUNDS[cup.round].name + (CUP_ROUNDS[cup.round].need > 1
? ' · FIRST TO ' + CUP_ROUNDS[cup.round].need + ' GAMES' : '') : undefined };
// a FLAT pool per body: with regicide gone the body is pure scoreboard (matchPoint takes a
// game's-quarter per point), so a deeper pool would change nothing — the ladder escalates in
// racketAI instead (matchSpeedPer / matchFlubKeep). THE CUP MULTIPLIES BY THE ROUND'S NEED and
@@ -668,9 +673,14 @@ function matchPoint(side) {
function matchHealBeat() {
const healed = Math.min(G.hpMax, G.hp + TUNE.matchHeal) - G.hp;
G.hp += healed;
if (healed > 0)
showModal({ title: 'WOUNDS TREATED', icon: 'x_cross', iconH: 52, color: UI.pick,
body: ['THE TRAINER PATCHED YOU UP', G.hp + ' OF ' + G.hpMax + ' BACK'] });
if (healed > 0) {
// NETPLAY: a CARD, never the modal — a modal freezes THIS world while the other phone's keeps
// moving, and an unread popup would hold the opponent's next serve hostage (the popup policy's
// live-court rule, applied to a court that is live on someone else's screen).
if (G.net) G.card = { title: 'WOUNDS TREATED', sub1: G.hp + ' OF ' + G.hpMax + ' BACK', color: UI.pick, t: 3.0 };
else showModal({ title: 'WOUNDS TREATED', icon: 'x_cross', iconH: 52, color: UI.pick,
body: ['THE TRAINER PATCHED YOU UP', G.hp + ' OF ' + G.hpMax + ' BACK'] });
}
}
// The game-winning point fells the body but no longer bursts it on the same frame: the KILL FALL
@@ -708,6 +718,10 @@ function matchGameWon(zb, banked = false) {
// the game but never touches the server — that flip lives in tennis's own pointTo, which the
// match bypasses (the same shape as the G.serveNum bug this file already carries a note about).
score.server = other(score.server);
// 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;
// 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; }
// Everything past the burst waits for it — the heal card, the boy, the walk-on clock.
d.afterFall = () => {
matchHealBeat();
@@ -830,7 +844,9 @@ export function resolveInterlude(side) {
// before it is applied, exactly as pointTo does for classic. netJudging() is false while a
// remote verdict is being applied, which is what stops the echo.
if (netJudging()) netSendPoint(side, null);
if (!zombies.some(z => z.variant === 'r') && !G.match.ending) return matchGameWon(null);
// the empty-court check reads "no body = regicide" — meaningless in netplay, where the court
// NEVER carries a body (the friend is the living O) and every point would read as a game
if (!G.net && !zombies.some(z => z.variant === 'r') && !G.match.ending) return matchGameWon(null);
return matchPoint(side);
}
G.interlude = false;
+8 -22
View File
@@ -79,10 +79,6 @@ export function netApplyShot(m) {
G.phase = 'rally'; G.swung = false; G.rallyNear = null;
O.x = s.x; O.z = s.z; // the strike happened where the ball says it did
O.pose = m.serve ? 'serve' : 'swing'; O.poseT = 0;
if (G.net.death) { // ...and in a death match the PICTURE is the zombie, not O
const rz = zombies.find(z => z.variant === 'r');
if (rz) { rz.x = s.x; rz.z = s.z; if (m.serve) rz.serveHold = 0.4; }
}
}
function netApplyPoint(m) {
@@ -117,7 +113,14 @@ function netStart(side, death) {
G.classic = false;
startMatch();
G.net = n;
G.modal = null; G.modalQ.length = 0; // the DEATH MATCH explainer must not eat the first serve
// THE OPPONENT IS THE FRIEND, NOT A ROSTER BODY: startMatch walked THE SHAMBLER on (spawn +
// name card) because it could not see the swap — take both back. The living O is the whole
// picture (render draws him whenever hideO is false, splatter included — the bench proved it),
// and the score reads on him as blood (bleedO), not as wound skins he does not have.
zombies.length = 0;
G.hideO = false;
G.card = null;
G.modal = null; G.modalQ.length = 0; // ...and the DEATH MATCH explainer must not eat the first serve
}
score.server = side === 'A' ? 'P' : 'O'; // the host serves first, on both scoreboards
score.pts = [0, 0];
@@ -169,23 +172,6 @@ export function netTick(dt) {
O.x += (tx - O.x) * k; O.z += (tz - O.z) * k;
if (O.pose !== 'swing' && O.pose !== 'serve') O.pose = o.m ? 'run' : 'idle';
if (o.m) O.runT += dt;
// DEATH MATCH: the body on screen is the racket zombie; O is only the serve-machinery ghost it
// always is in a match. The zombie shadows O's driven position — stride bookkeeping like the
// bench's walk, so the frames step off ground covered — except while the fall or a named serve
// frame owns the picture. vx is recorded so a fall inherits the run, clamped against snaps.
if (n.death && !G.matchFall) {
const rz = zombies.find(z => z.variant === 'r');
if (rz && !rz.showFrame) {
const dx = O.x - rz.x, step = Math.hypot(dx, O.z - rz.z);
if (step > 0.03) {
rz.dist = (rz.dist || 0) + step;
rz.vx = Math.max(-3, Math.min(3, dx / Math.max(dt, 1e-3)));
if (Math.abs(dx) > 0.02) rz.face = dx > 0 ? 1 : -1;
rz.pose = rz.pose === 'swing' ? 'swing' : 'run';
} else { rz.pose = rz.pose === 'swing' ? 'swing' : 'idle'; rz.vx = 0; }
rz.x = O.x; rz.z = O.z;
}
}
}
}
+20 -1
View File
@@ -4,7 +4,7 @@
// (zombies <-> gameplay is a runtime-only import cycle, same pattern as input.)
import { TUNE, UI, COURT, CONTACT, BOSS_ART, bossArt, WEAPONS, BARREL_WEAPONS, woundStage, zombieH, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R } from './config.js';
import { G, P, ball, score, sendBoy, setupPoint } from './state.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 } from './audio.js';
import { drillKill, drillCaught } from './drill.js';
import { splatPoint, cheer, spentBall, aside, grantWeapon, endBurst, sliceCarom } from './gameplay.js';
@@ -921,6 +921,24 @@ function gushProne(zb) {
}
}
// 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() {
const k = Math.min(3, score.pts[0]); // points off them THIS game
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;
@@ -962,6 +980,7 @@ export function updateParts(dt) {
export function updateZombies(dt) {
moanT -= dt;
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;
+20 -18
View File
@@ -1283,26 +1283,37 @@ function freshNetDeath(side) {
netMsg({ t: 'start', side, death: true });
}
test('netplay death match: the host\'s pick rebuilds the court on pairing', () => {
test('netplay death match: the host\'s pick rebuilds the court on pairing — friend, not roster body', () => {
freshNetDeath('B');
assert.ok(G.net && G.net.ready && G.net.death, 'the wire survives the mode swap');
assert.equal(G.mode, 'splatter');
assert.ok(G.match && G.interlude, 'the match machinery owns the court');
assert.ok(zombies.some(z => z.variant === 'r'), 'the body is on court');
assert.ok(!zombies.some(z => z.variant === 'r'), 'NO roster body — the friend IS the opponent');
assert.equal(G.hideO, false, 'the living opponent is the picture');
assert.equal(G.card, null, 'and no SHAMBLER walk-on card');
assert.equal(score.server, 'O', 'the host serves first here too');
assert.equal(G.modal, null, 'no popup may eat the first remote serve');
});
test('netplay death match: their verdicts wound the body, mine cost blood — no echo', () => {
test('netplay death match: their lost points read as BLOOD, and the game bursts the body', () => {
freshNetDeath('A');
const rz = zombies.find(z => z.variant === 'r');
const hp0 = rz.hp;
netMsg({ t: 'pt', win: 'you' }); // their phone judged: my ball beat them
assert.ok(rz.hp < hp0, 'my point tears at the body');
assert.equal(score.pts[0], 1, 'the point banks — bleedO rides this ramp');
assert.ok(!netTest.sent.some(m => m.t === 'pt'), 'applying a verdict does not echo one back');
const blood = G.hp;
netMsg({ t: 'pt', win: 'me' }); // their phone judged: their ball beat me
assert.equal(G.hp, blood - 1, 'their point draws my blood');
score.pts = [3, 0]; // game point against them: the flow at full ramp
const before = parts.length;
for (let i = 0; i < 30; i++) update(DT);
assert.ok(parts.length > before, 'a deeply wounded friend bleeds dynamically (bleedO)');
netMsg({ t: 'pt', win: 'you' }); // the game-winning point
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, true, 'the burst took the body');
for (let i = 0; i < 60 * 25 && G.hideO; i++) update(DT);
assert.equal(G.hideO, false, 'the mop beat hands the court back and the friend rises again');
assert.equal(G.match.opp, 1, 'no roster climb — the same friend, every game');
});
test('netplay death match: the receiver judges — an untouched winner draws blood and crosses the wire', () => {
@@ -1329,18 +1340,9 @@ test('netplay death match: bleeding out is said on the wire, and the other phone
assert.equal(G.match.felled, 3, 'felled = the games that actually burst the body');
});
test('netplay death match: the same friend walks back on — no roster climb', () => {
freshNetDeath('A');
const opp0 = G.match.opp;
for (let i = 0; i < 4; i++) netMsg({ t: 'pt', win: 'you' }); // four points = the game = the burst
assert.equal(G.match.opp, opp0, 'the opponent number holds through the game win');
for (let i = 0; i < 60 * 25 && !zombies.some(z => z.variant === 'r'); i++) update(DT);
assert.ok(zombies.some(z => z.variant === 'r'), 'the body returns after the mop beat');
assert.equal(G.match.opp, opp0, 'as the same opponent, not the roster\'s next rung');
});
test('death match: the game-winning body bleeds out on the ground before it bursts', () => {
freshNetDeath('A');
test('death match (solo): the game-winning body bleeds out on the ground before it bursts', () => {
assert.ok(_startMatch(), 'solo match on a clean court');
G.modal = null; G.modalQ.length = 0;
const rz = zombies.find(z => z.variant === 'r');
G.matchFall = { t: 0.3, from: 1, vx: 0, x0: rz.x, kill: true }; // landed, prone, pre-burst
const before = parts.length;