The dead club doubles its staff, and the draw is made where you can see it

Six new opponent families (head coach, stringer, strawberry seller, chair
umpire, spectator, roller man) through the famroll/zsheet route, placed by
hierarchy into an 18-rung ladder; the champion steps up behind them.

THE DRAW: picking the tournament now rolls its own bracket in front of you —
only the final was ever settled — and what lands is what walks on.

Netplay, off Oliver's cellular report: the mid-flight early-decide was missing
its !G.net gate, so the striker judged its own outbound flight and the two
scoreboards drifted a point apart into a court nobody could serve on. The
host's court now rides the room, its scoreboard rides the pretoss stream, and
a called ball rolls to its own halt instead of freezing where the call caught it.

Plus the night court, and somebody in the stand is taking pictures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-15 11:49:19 +02:00
1 parent 4c47295a73
commit 9cc3ac54b3
207 files changed
+632 -81

No files matched your search

+37 -9
View File
@@ -18,7 +18,7 @@
// 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 { G, P, O, ball, score, setupPoint, selectCourt, court } from './state.js';
import { pointTo, fault, showModal, 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';
@@ -83,7 +83,7 @@ export function netApplyShot(m) {
function netApplyPoint(m) {
applying = true; // their verdict — apply it without judging or echoing
ball.active = false;
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);
@@ -92,14 +92,36 @@ function netApplyPoint(m) {
function netApplyFault() {
applying = true;
ball.active = false;
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;
}
function netStart(side, death) {
// 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) {
G.net.ready = true; G.net.side = side;
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
@@ -145,7 +167,7 @@ export function netMsg(m) {
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, m.death);
else if (m.t === 'start') netStart(m.side, m.death, m.court);
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 === 'toss') netApplyToss();
@@ -153,7 +175,7 @@ export function netMsg(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;
else if (m.t === 'pos') { G.net.opp = m; if (m.srv) netSync(m); }
}
// ticked from update() while G.net: the position stream out, the O sprite in
@@ -162,7 +184,10 @@ export function netTick(dt) {
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 });
// 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) {
@@ -175,9 +200,12 @@ export function netTick(dt) {
}
}
export function netConnect(code, death = false) {
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' + (code ? '?room=' + encodeURIComponent(code) : death ? '?death=1' : '');
+ '/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');