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

+146
View File
@@ -0,0 +1,146 @@
// NETPLAY — 1v1 classic tennis between two phones, over the dumb relay in server.py.
// (gameplay <-> net is a runtime-only import cycle, same pattern as input <-> gameplay: the
// imported bindings are only ever touched inside functions, never during module evaluation.)
//
// THE SYNC UNIT IS THE SHOT, NOT WORLD STATE (PROJECT.md, LATER item 7). Each phone runs the whole
// sim with ITSELF as P; the opponent's court is this one rotated half a turn about the net centre
// (mirror() below). A struck ball is ~9 numbers; each ~1 s flight swallows the latency.
//
// WHO JUDGES WHAT — the one rule that keeps two skewed sims from double-scoring a rally:
// THE PHONE THE BALL IS FLYING TOWARD IS THE ONLY JUDGE OF THAT FLIGHT. Your swing window is
// judged on your phone (lag never eats your tap), and so are the line call, the service box and
// the double bounce of everything that comes AT you. The striker's phone renders its outbound
// ball but judges nothing (gameplay's bounce block returns early on it) and waits for one of
// three verdicts: 'shot' (returned — snap the ball to the mirrored strike), 'fault' (re-serve),
// 'pt' (the point, winner relative to the SENDER). The 10 Hz 'pos' stream is pure picture — it
// drives the O sprite and carries no authority at all.
//
// ponytail: no reconnect-and-resume. A dropped radio ends the match with a card; the recorded
// plan calls reconnection "the bulk of the real work" and a prototype must not start there.
import { COURT, UI } from './config.js';
import { G, P, O, ball, score, setupPoint } from './state.js';
import { pointTo, fault, showModal } from './gameplay.js';
let ws = null, hb = 0, posT = 0;
let applying = false; // a remote verdict being applied must not echo back out
export const netTest = { sent: null }; // test seam: an array here catches outgoing messages
const r3 = v => Math.round(v * 1000) / 1000;
// the half-turn about the net centre: x -> -x, z -> COURT.L - z, velocities with them
export const mirror = m => ({ ...m, x: -m.x, z: COURT.L - m.z, vx: -m.vx, vz: -m.vz });
function send(m) {
if (netTest.sent) { netTest.sent.push(m); return; }
if (ws && ws.readyState === 1) ws.send(JSON.stringify(m));
}
// true while THIS phone is the judge of the live flight and the verdict is its own (not an echo)
export const netJudging = () => !!(G.net && G.net.ready) && !applying;
export const netSendPoint = (side, reason) => send({ t: 'pt', win: side === 'P' ? 'me' : 'you', reason: reason || null });
export const netSendFault = () => send({ t: 'fault' });
// called by gameplay the moment a local strike has finished writing the ball — the ball IS the message
export function netSendShot() {
if (!G.net || !G.net.ready) return;
send({ t: 'shot', x: r3(ball.x), y: r3(ball.y), z: r3(ball.z),
vx: r3(ball.vx), vy: r3(ball.vy), vz: r3(ball.vz),
g: r3(ball.g), pow: r3(ball.pow || 0), serve: !!G.wasServe });
}
// the opponent struck: their ball, mirrored, becomes the live ball — and the authority flips here
export function netApplyShot(m) {
const s = mirror(m);
ball.active = true; ball.collected = false; ball.bounces = 0;
ball.x = s.x; ball.y = m.y; ball.z = s.z;
ball.vx = s.vx; ball.vy = m.vy; ball.vz = s.vz;
ball.g = m.g; ball.pow = m.pow;
ball.lastHit = 'O';
ball.bomb = false; ball.flame = false; ball.wid = 'ball'; ball.chain = 0; ball.praise = null; ball.kills = 0;
G.wasServe = !!m.serve; // arms the receiver's own service-box call
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;
}
function netApplyPoint(m) {
applying = true; // their verdict — apply it without judging or echoing
ball.active = false;
pointTo(m.win === 'me' ? 'O' : 'P', m.reason);
applying = false;
}
function netApplyFault() {
applying = true;
ball.active = false;
fault(); // we are the server here: second serve arms exactly as ever
applying = false;
}
function netStart(side) {
G.net.ready = true; G.net.side = side;
G.modal = null; G.modalQ.length = 0; // the waiting card has done its job
score.server = side === 'A' ? 'P' : 'O'; // the host serves first, on both scoreboards
score.pts = [0, 0];
G.serveNum = 1;
setupPoint();
G.flash = { text: side === 'A' ? 'AN OPPONENT — YOUR SERVE' : 'AN OPPONENT — THEIR SERVE', t: 2.4 };
}
function netDead(text) {
if (!G.net || G.net.dead) return;
G.net.ready = false; G.net.dead = true;
ball.active = false;
G.modal = null; G.modalQ.length = 0;
G.card = { title: 'CONNECTION LOST', sub1: text, color: UI.bad, t: 30 };
}
export function netMsg(m) {
if (!G.net) return;
if (m.t === 'room') {
G.net.code = m.code;
// a modal is legal here: an empty court waiting for a stranger is the calmest court there is
showModal({ title: m.code, color: UI.pick,
body: ['THE ROOM CODE — TELL YOUR OPPONENT', 'WAITING FOR THEM TO JOIN'] });
}
else if (m.t === 'start') netStart(m.side);
else if (m.t === 'gone') netDead('NO SUCH ROOM — RELOAD AND HOST ONE');
else if (m.t === 'bye') netDead('YOUR OPPONENT LEFT — RELOAD TO PLAY AGAIN');
else if (m.t === 'shot') netApplyShot(m);
else if (m.t === 'pt') netApplyPoint(m);
else if (m.t === 'fault') netApplyFault();
else if (m.t === 'pos') G.net.opp = m;
}
// ticked from update() while G.net: the position stream out, the O sprite in
export function netTick(dt) {
const n = G.net;
if (!n || !n.ready) return;
if ((posT += dt) >= 0.1) {
posT = 0;
send({ t: 'pos', x: r3(P.x), z: r3(P.z), m: P.pose === 'run' ? 1 : 0 });
}
const o = n.opp;
if (o) {
const tx = -o.x, tz = COURT.L - o.z;
const k = Math.min(1, dt * 9); // a short lerp: smooths the 10 Hz stream, adds no lag worth feeling
if (o.m && Math.abs(tx - O.x) > 0.2) O.facing = tx > O.x ? 1 : -1;
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;
}
}
export function netConnect(code) {
const url = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host
+ '/ws' + (code ? '?room=' + encodeURIComponent(code) : '');
ws = new WebSocket(url);
ws.onmessage = e => { let m; try { m = JSON.parse(e.data); } catch { return; } netMsg(m); };
ws.onclose = () => netDead('THE LINE DROPPED — RELOAD TO PLAY AGAIN');
hb = setInterval(() => send({ t: 'hb' }), 20000); // keeps phone radios and proxies from idling us out
}
export function netClose() {
if (hb) { clearInterval(hb); hb = 0; }
if (ws) { try { ws.onclose = null; ws.close(); } catch { /* already gone */ } ws = null; }
}