// 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, TOURS, NATIVE, HOST } from './config.js'; import { G, P, O, ball, score, setupPoint, selectCourt, court, kit, skin, tour } 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. // `u` is the TOUR — which body they play in, not just what colour it wears. It rides the same // message because it is the same fact: what the friend across the net LOOKS like. Without it both // phones drew the opponent in whatever tour THIS phone picked, so a men's/women's pairing showed // each player a body their friend was not using. send({ t: 'look', k: kit.id, s: skin.id, n: savedName(), u: tour.id }); 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], // a build that predates the tour field sends none, and the fallback is what it used to do: // draw them in OUR tour. Same rule as the kit — an unknown id resolves to the drawn default. tour: TOURS.find(t => t.id === m.u) || tour, 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 // ...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 } : {}) }); } 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 = (NATIVE ? 'wss://' + HOST // the app: the live host, by name : (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; } }