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

+31 -7
View File
@@ -14,6 +14,7 @@ import { voice, zombieDuck, aww } from './audio.js';
import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie, explode, killPlayer } from './zombies.js';
import { cheer, aside, endBurst, deliverTin, showModal, logSwing, advancePoint, scoreKey } from './gameplay.js';
import { startDrillStream } from './drill.js';
import { netClose } from './net.js';
// the court is mopped up (or was already clear): play advances to whatever the won game earned —
// a changeover breather, a plain even-game beat, or (on a set) the centered SET card then a horde.
@@ -64,7 +65,7 @@ function startChangeover() {
export function startDuel() {
// Hard mutual exclusion, HERE, where every caller routes through: a duel must never begin while
// another control mode owns the court. Each of these has ended a run when they overlapped.
if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench) return;
if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net) return;
if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // the schedule guarantees an opponent
for (const z of zombies) if (z.variant === 'r') z.dueled = true; // a veteran can be challenged again — this marks the fresh duel
G.weapon = 0; // tennis rules for the duel: plain balls, no tins
@@ -87,7 +88,7 @@ export function startDuel() {
// It threatens BOTH channels: its volleys chip your hp, and letting it reach you is the ordinary
// instant death. G.bossFight holds the chapter number while it runs.
export function startBossFight(chapter) {
if (G.bossFight || G.drill || G.match || G.bench) return;
if (G.bossFight || G.drill || G.match || G.bench || G.net) return;
G.bossFight = chapter;
zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0; // a clean stage
G.horde = null; G.mgT = 0; endBurst(); G.clearing = false;
@@ -136,7 +137,7 @@ export function endBossFight() {
// was walking around a moment ago is still walking around inside a drill round. The drill's own
// clock and rules live in drill.js; only the transition is here, where the rule can be enforced.
export function startDrill() {
if (G.drill || G.interlude || G.horde || G.bossFight || G.match || G.bench) return false;
if (G.drill || G.interlude || G.horde || G.bossFight || G.match || G.bench || G.net) return false;
G.drill = { t: 0, spawnT: 0, score: 0, streak: 0, kills: 0, faults: 0, lost: 0, best: 0, msg: null };
G.changeover = false; G.clearing = false; G.afterClear = null;
// ...and DISARM the ordinary schedule. Taking the court is not enough on its own: a pending
@@ -171,7 +172,7 @@ export function endDrill() {
const BENCH_HP = 12; // a deep pool so a wound stage is set by FRACTION, cleanly
export function startBench() {
if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench) return false;
if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net) return false;
G.bench = { i: 0, stage: 0, fam: 0, dir: 1 };
zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0;
// ...and DISARM the ordinary schedule, exactly as the drill does: a pending horde or an owed
@@ -342,7 +343,7 @@ export function watchdog(dt) {
// it, and killing it outright wins the game early (killZombie fires resolveInterlude('P'), which
// lands in matchGameWon via the empty-court check).
export function startMatch(cup = false) {
if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench) return false;
if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net) return false;
G.match = { opp: 1, ending: false };
// THE TOURNAMENT rides the whole match machinery and differs in exactly three places: who walks
// on (a rung DRAWN from the round's band — cupDraw sets d.opp, so every ladder dial downstream
@@ -827,12 +828,35 @@ export function resolveInterlude(side) {
}
}
// ---- NETPLAY: 1v1 classic tennis against another phone. The wire, the protocol and the judging
// rule all live in src/net.js; ONLY the court ownership lives here, where the contract can be
// enforced — same shape as every mode above. It runs in G.mode 'tennis' with G.classic set (the
// caller does that via start('classic')), so the whole splatter schedule is structurally inert;
// the disarm lines below are the same belt the drill and the bench wear anyway.
export function startNetVs() {
if (G.net || G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench) return false;
G.net = { ready: false, dead: false, side: null, code: null, opp: null };
G.pendingHorde = false; G.hordeWarn = 0; G.bossDue = 0; G.duelDue = false;
G.changeover = false; G.clearing = false; G.afterClear = null;
G.weapon = 0; G.mgT = 0; endBurst(); G.boy = null;
zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0;
ball.active = false; setupPoint();
return true;
}
export function endNetVs() {
if (!G.net) return;
netClose();
G.net = null;
ball.active = false; setupPoint();
}
// THE BREATHER BEFORE THE WAVE. The horde used to begin on the same frame as its own announcement,
// so the flash was already gone by the time the crowd was on you — "the horde is not announced
// anymore". It is an ordinary 'over' beat (no serving, the world keeps walking, the RESUMING clock
// counts it down under the flash), and startHorde still owns the transition itself.
export function warnHorde() {
if (G.horde || G.interlude || G.bossFight || G.drill || G.match || G.bench) return false; // same court-ownership contract as the rest
if (G.horde || G.interlude || G.bossFight || G.drill || G.match || G.bench || G.net) return false; // same court-ownership contract as the rest
G.hordeWarn = TUNE.hordeWarnT;
G.flash = { text: 'THEY KEEP COMING', t: TUNE.hordeWarnT };
ball.active = false; G.phase = 'over'; G.overT = TUNE.hordeWarnT;
@@ -843,7 +867,7 @@ export function warnHorde() {
// Returns false when another mode owns the court — the caller re-arms the latch rather than
// silently eating the wave the set paid for.
export function startHorde() {
if (G.horde || G.interlude || G.bossFight || G.drill || G.match || G.bench) return false; // same contract as startDuel: one mode owns the court
if (G.horde || G.interlude || G.bossFight || G.drill || G.match || G.bench || G.net) return false; // same contract as startDuel: one mode owns the court
G.changeover = false; // a cheat-forced horde mid-changeover must not leave spawns locked
const grow = 1 + (G.sets[0] + G.sets[1]) * TUNE.hordeGrow; // each set banked makes the next wave worse
// the gun grows WITH the wave: spawn rate and length both scale by grow, so a flat gun fell