Files
splatter-tennis/src/net.js
T
wuwiandClaude Opus 5 425c18dcb6 A room you can share by link, a code you can type, and a name to be called
Multiplayer setup moves out of a canvas modal and a prompt() dialog into one
menu screen: your name, the two ways to host, and the friend's code. The socket
opens from the menu and the game stays parked behind the overlay until the room
fills — netHooks.paired takes the court one line before netStart writes to it —
so the room code can carry a real SHARE button and a wrong code is a typo you
fix rather than a CONNECTION LOST card over a match that never began.

The name is the world board's name (stats.saveName), crosses on the existing
look message, and the scoreboard finally names the person instead of CPU.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 16:20:39 +02:00

247 lines
14 KiB
JavaScript

// 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, KITS, SKINS } from './config.js';
import { G, P, O, ball, score, setupPoint, selectCourt, court, kit, skin } from './state.js';
import { pointTo, fault, tossBall } from './gameplay.js';
import { startMatch, matchReady, resolveInterlude, netMatchWon } from './beats.js'; // runtime-only cycle: beats sends verdicts back through this module
import { zombies } from './zombies.js';
import { savedName, tidyName } from './stats.js';
// main.js hangs its court builders and its LOBBY here, so this module never has to know how a
// splatter court is entered or what the menu looks like. Headless tests leave them empty and net
// falls back to the minimal direct entry, which is exactly what the suite's own freshSplatter does.
// intoSplatter the death match's court rebuild
// paired the room filled: leave the menu and take the court, BEFORE netStart touches it
// room the code the relay minted (the lobby shows it and shares the link)
// lost the line died; returns true if the lobby said so itself and needs no card
export const netHooks = { intoSplatter: null, paired: null, room: null, lost: null };
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' });
export const netSendToss = () => { if (G.net && G.net.ready) send({ t: 'toss' }); };
export const netSendDead = () => send({ t: 'dead' }); // my blood ran out — their phone wins on it
// their toss: replay it as a REAL toss over O, so the receiver watches the serve coming. Everything
// downstream is machinery that already exists — update() poses the server off phase 'toss', the
// uncaught-toss reset drops a cancelled one back to pretoss on both phones by plain physics, and
// the strike's shot event overwrites the ball wherever the two sims had drifted to.
function netApplyToss() {
if (score.server !== 'O' || G.phase === 'rally') return; // a stray toss must not touch a live rally
tossBall(O);
}
// 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.scored = true; // ...and OUR ball keeps rolling to its own halt (see endFlight)
const side = m.win === 'me' ? 'O' : 'P';
if (G.match) resolveInterlude(side); // death match: the interlude's plumbing owns the point
else pointTo(side, m.reason);
applying = false;
}
function netApplyFault() {
applying = true;
ball.scored = true; // the fault is called; the serve still lands and rolls away
fault(); // we are the server here: second serve arms exactly as ever
applying = false;
}
// THE SCOREBOARDS RECONCILE, and the host is the tie-break. Every verdict is a message, and a
// message that never lands leaves the two sims a point apart forever — which is not a cosmetic
// drift: setupPoint reads score.pts for the deuce/ad side and score.server for who tosses, so one
// lost 'pt' put BOTH players on the same half of their own court, each waiting for the other to
// serve, with no way out (Oliver, over cellular). The gate is `pretoss` ONLY: that is the settled
// state between points, where nothing is mid-beat and any verdict still in flight has long landed.
// Mirrored like everything else here — their P is our O.
// ponytail: server + points only. score.games can drift too, but it costs a wrong number on a
// board rather than a court nobody can serve on; sync it if that ever actually happens.
function netSync(m) {
if (!G.net.ready || G.net.dead || G.phase !== 'pretoss' || !m.p) return;
const srv = m.srv === 'P' ? 'O' : 'P', pts = [m.p[1], m.p[0]];
if (score.server === srv && score.pts[0] === pts[0] && score.pts[1] === pts[1]) return;
score.server = srv; score.pts = pts;
if (G.match) matchReady(); else setupPoint();
}
function netStart(side, death, courtId) {
// THE LOBBY HANDS THE COURT OVER FIRST. Hosting and joining both happen on the menu now (the
// code and its share link are DOM, not a canvas modal), so the game is still parked behind the
// overlay when the room fills — and everything below writes to a court that must already exist,
// with the kit and tour this phone picked already selected (the look message reads them).
if (netHooks.paired) netHooks.paired();
G.net.ready = true; G.net.side = side;
// tell them how we look AND WHO WE ARE, the moment there is somebody to tell. Sent once, on
// pairing: neither phone can change its pick mid-match, because the setup screen is only
// reachable between runs.
send({ t: 'look', k: kit.id, s: skin.id, n: savedName() });
G.modal = null; G.modalQ.length = 0; // the waiting card has done its job
// THE HOST'S COURT RIDES THE ROOM, exactly as the death-match pick does — two phones playing the
// same rally on two different surfaces disagree about the bounce as well as the picture, and the
// guest never chose. Safe mid-run: selectCourt copies the bounce pair into TUNE and the backdrop
// cache keys on court.id, so the next frame repaints.
if (courtId && courtId !== court.id) selectCourt(courtId);
if (death && !G.match) {
// THE HOST PICKED DEATH MATCH, and the start message carries it to both phones — the joiner
// (and the host itself) has been waiting on an ordinary classic court, so the court is rebuilt
// here, under the same frame the pairing lands on. G.net steps aside for the builders because
// startMatch refuses a court netplay owns (the guard is right; this composition is the one
// sanctioned exception and it happens through this swap, never by weakening the guard).
G.net.death = true;
const n = G.net; G.net = null;
if (netHooks.intoSplatter) netHooks.intoSplatter();
else { G.mode = 'splatter'; G.everTransitioned = true; } // headless: the suite's own entry
G.classic = false;
startMatch();
G.net = n;
// 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];
G.serveNum = 1;
if (G.match) matchReady(); else 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;
// the lobby is a menu screen: while it is up there is no court to lay a card on, and it says the
// bad news in its own words (a wrong code is a typo to fix, not a match that ended)
if (netHooks.lost && netHooks.lost(text)) return;
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;
if (netHooks.room) netHooks.room(m.code); // the lobby: the code big, and a link to send them
}
else if (m.t === 'start') netStart(m.side, m.death, m.court);
// 'gone' can only ever reach a phone that is JOINING, and joining happens in the lobby — so it is
// a typo to fix on the screen you are already looking at, not a match that ended
else if (m.t === 'gone') netDead('NO SUCH ROOM — CHECK THE CODE');
else if (m.t === 'bye') netDead('YOUR OPPONENT LEFT — RELOAD TO PLAY AGAIN');
else if (m.t === 'toss') netApplyToss();
else if (m.t === 'shot') netApplyShot(m);
else if (m.t === 'pt') netApplyPoint(m);
else if (m.t === 'fault') netApplyFault();
else if (m.t === 'dead') netMatchWon(); // their blood ran out: this side's win ending
else if (m.t === 'pos') { G.net.opp = m; if (m.srv) netSync(m); }
// WHAT THEY LOOK LIKE AND WHAT THEY ARE CALLED. Their phone's kit and skin, resolved against our
// own tables so a build that has never heard of a colour falls back to the drawn one rather than
// drawing nothing. Unknown message types are ignored above, so an older build simply never sends
// this and their friend sees the opponent as drawn, called FRIEND — the match still plays.
// The name is a stranger's string on OUR scoreboard: it goes through the same tidyName the world
// board uses, then into the board's 8-character slot.
else if (m.t === 'look') G.net.look = {
kit: KITS.find(k => k.id === m.k) || KITS[0],
skin: SKINS.find(s => s.id === m.s) || SKINS[0],
name: tidyName(m.n || '').slice(0, 8) || 'FRIEND',
};
}
// 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;
// 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
send({ t: 'pos', x: r3(P.x), z: r3(P.z), m: P.pose === 'run' ? 1 : 0,
...(n.side === 'A' && G.phase === 'pretoss' ? { srv: score.server, p: score.pts } : {}) });
}
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, death = false, courtId = null) {
// joining carries only the code — the host's room already knows the mode and the court
const q = code ? ['room=' + encodeURIComponent(code)]
: [death ? 'death=1' : '', courtId ? 'court=' + encodeURIComponent(courtId) : ''].filter(Boolean);
const url = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host
+ '/ws' + (q.length ? '?' + q.join('&') : '');
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; }
}