Two phones, one match: the shot is the message, the receiver is the judge

VS A FRIEND on the mode screen (host a code, join a code), a ~110-line
RFC6455 relay in server.py that holds no game state, and src/net.js: each
phone plays itself near-side and the opponent's court is this one rotated
half a turn about the net. The phone a ball flies toward makes every call
on it; the striker's copy is picture. Six tests pin the rule from both
seats. No reconnect yet — a dropped radio is a card and a reload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-14 17:36:02 +02:00
1 parent 108ed695eb
commit f302354bb2
11 files changed
+493 -21

No files matched your search

+26 -5
View File
@@ -9,6 +9,7 @@ import { updateDrill } from './drill.js';
import { endRun, beginRun, MODE_CLASSIC } from './stats.js';
import { updateZombies, updateParts, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies , firePellet , spawnBarrel , fireBeam , pops, popText } from './zombies.js';
import { proceedAfterClear, startDuel, startHorde, warnHorde, resolveInterlude, watchdog, updateBench, benchKey, tickMatchFall } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing
import { netSendShot, netTick, netJudging, netSendPoint, netSendFault, netClose } from './net.js'; // runtime-only cycle: net calls back into pointTo/fault/showModal
// ------------------------------------------------------------- ballistics ----
// Launch ball from (x0,y0,z0) so it arrives at (tx, tz) with horizontal speed s under gravity g.
@@ -227,6 +228,7 @@ function doHit(ch, quality, aim) {
if (w) popText(ball.x, ball.y, ball.z, w);
}
ch.pose = 'swing'; ch.poseT = 0;
if (G.net && ch.side === 'P') netSendShot(); // netplay: the shot IS the sync unit
}
// ----------------------------------------------------------------- serve ----
@@ -460,6 +462,7 @@ function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
// bounce. Praising at release meant the HUD spoke while the ball was still mid-air.
ball.praise = praiseWord();
}
if (G.net && sv.side === 'P') netSendShot(); // netplay: a serve is a shot event like any other
}
// ---------------------------------------------------------------- scoring ----
@@ -539,7 +542,11 @@ export function advancePoint(side) { // shared scoring math; returns whether a g
return false;
}
function pointTo(side, reason) {
export function pointTo(side, reason) { // exported for net.js: a remote verdict lands here
// netplay: this phone judged the live flight — the verdict goes over the wire before it is
// applied, so both scoreboards move on the same call. netJudging() is false while a REMOTE
// verdict is being applied, which is what stops the two phones echoing a point back and forth.
if (netJudging()) netSendPoint(side, reason);
if (G.mode === 'tennis' && side === 'O' && ball.lastHit === 'O' && G.rallyNear && !G.swung) { // their ball died on your side untouched — and no whiff already explains it
const n = G.rallyNear;
logSwing({ result: 'PASSED', reason: `never swung in range — ball got ${r2(n.d)}m away at y=${r2(n.y)}m`,
@@ -859,6 +866,7 @@ const xLimit = () => Math.max(xVisible(), COURT.W / 2);
// bag has a pristine copy (state.resetState) and the two modules with timers of their own put them
// back themselves, so nothing here has to know the shape of any of it.
export function restartRun() {
netClose(); // netplay: resetState wipes G.net below; the socket must not outlive the run it carried
resetZombies(); resetInput(); resetState(); setupPoint();
beginRun(); // ...and the stats bag, which is the only state that does not live on G
}
@@ -917,8 +925,12 @@ function transition(dt) {
});
}
function fault() {
if (G.serveNum === 1) { G.serveNum = 2; voice('fault'); G.phase = 'over'; G.overT = 1.4; sendBoy(); }
export function fault() { // exported for net.js: the receiver's fault call re-arms the server's second serve
if (G.serveNum === 1) {
if (netJudging()) netSendFault(); // netplay: only the first-serve branch wires — the double
// fault below goes out through pointTo as the point it is
G.serveNum = 2; voice('fault'); G.phase = 'over'; G.overT = 1.4; sendBoy();
}
else pointTo(other(score.server), 'double_fault');
}
@@ -1031,6 +1043,11 @@ function onBounce() {
return;
}
if (G.mode !== 'tennis') return; // no lines, no faults — the umpire has other concerns
// NETPLAY: THE RECEIVING PHONE IS THE ONLY JUDGE OF A FLIGHT. Our outbound ball is picture, not
// evidence — the two sims are ~a latency apart, and this one would call a double bounce before
// the other one's player had swung. Every call on this ball (box, line, second bounce) arrives
// as a message instead; the ball rolls on harmlessly until it does. (See net.js.)
if (G.net && ball.lastHit === 'P') return;
if (ball.bounces === 1) {
const wasServe = G.wasServe; G.wasServe = false;
if (wasServe) {
@@ -1365,6 +1382,7 @@ export function keyPressed(code) {
if (G.horde || G.mgT > 0) return; // auto-fire modes: a tap only swishes, never serves
if (G.changeover) return; // the breather is mandatory — the melee swish above still works
if (G.interlude && G.interlude.introT > 0) return; // let the umpire finish announcing the duel
if (G.net && (!G.net.ready || G.net.dead)) return; // netplay: no serving into an empty room
// splatter: a tap/flick during the brief 'over' beat re-arms the next serve immediately
if (G.mode === 'splatter' && G.phase === 'over') { ball.active = false; setupPoint(); }
// splatter serve stays a TWO-STAGE ritual — toss up, THEN fire. It can only start from
@@ -1693,6 +1711,7 @@ export function update(dt) {
if (G.meleeT > 0) G.meleeT -= dt;
if (G.rackGlow && (G.rackGlow.t -= dt) <= 0) G.rackGlow = null; // the fresh-pickup pulse fades out
watchdog(dt);
if (G.net) netTick(dt); // netplay: the 10 Hz position stream out, the opponent's body in
// !G.horde: a horde mows the court down to an EMPTY FRAME between spawns, and a clear latched
// before the wave began (a game won during the set card) then advanced the beat mid-horde —
// which is how a sudden-death duel once started INSIDE a horde. The clear waits for endHorde.
@@ -1717,7 +1736,8 @@ export function update(dt) {
// own baseline and arms aiServeT; this is the one condition that was keeping it switched off.
// The intro beat holds it: a body still walking on must not open with a serve.
const oServes = (G.mode === 'tennis' || G.match) && score.server === 'O'
&& !(G.interlude && G.interlude.introT > 0);
&& !(G.interlude && G.interlude.introT > 0)
&& !G.net; // netplay: the far side serves on the OTHER phone — the serve arrives as a shot event
if (oServes && G.phase === 'pretoss') {
G.aiServeT -= dt;
if (G.aiServeT <= 0) tossBall(O);
@@ -1728,7 +1748,8 @@ export function update(dt) {
if (G.match) { const rz = zombies.find(z => z.variant === 'r'); if (rz) rz.serveHold = 0.4; }
}
}
if (G.mode === 'tennis' && G.phase === 'rally') {
if (G.mode === 'tennis' && G.phase === 'rally' && !G.net) { // netplay: O is a real person on
// another phone — netTick drives the body
G.aiReactT += dt;
const incoming = ball.active && ball.lastHit === 'P'
&& G.aiReactT >= (G.diff ? G.diff.reaction : TUNE.aiReaction);