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>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-15 16:20:39 +02:00
1 parent ad258ace97
commit 425c18dcb6
7 files changed
+255 -75

No files matched your search

+78 -42
View File
@@ -6,7 +6,7 @@
import { TUNE, COURTS, TOURS, KITS, SKINS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt, CUP_ROUNDS, matchFoe } from './config.js';
import { P, O, ball, score, G, setupPoint, selectCourt, selectTour, selectKit, selectSkin } from './state.js';
import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, startGore, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, flickTarget, dragServe, toggleTestMode } from './gameplay.js';
import { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench, FALL, benchFall, startNetVs } from './beats.js';
import { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench, FALL, benchFall, startNetVs, endNetVs } from './beats.js';
import { netConnect, netHooks } from './net.js';
import { fitViewport, render, proj, RES, SPRV, LOAD, SPR, drawLook } from './render.js';
import { voice, ambient, menuMusic, menuDuck } from './audio.js';
@@ -100,39 +100,11 @@ const banner = (card, cls, last) => {
const pickMode = id => { pickedMode = id; try { localStorage.setItem('mode', id); } catch {} };
// TAP ADVANCES. Each screen's tap both answers and moves on — the wizard's whole promise is one
// decision per screen, and a NEXT button under a single row of choices is a second tap for nothing.
// NETPLAY's second step: two chips, and either one IS the start — a room needs a socket, not a
// setup screen (court and tour ride whatever was last picked). HOST shows the code to share in a
// modal over the frozen court; JOIN asks for a friend's code.
// ponytail: prompt() is the join UI — replace with a drawn code entry if the mode ships.
// The host's one decision — classic or the death match — rides the room, so the joiner never picks
// a mode: the start message carries it to both phones and both courts are rebuilt on pairing.
function netChip(label, join, death = false) {
const b = document.createElement('button');
b.className = 'chip';
b.textContent = label;
b.addEventListener('click', () => {
let code = null;
if (join) {
code = (prompt('ROOM CODE FROM YOUR OPPONENT') || '').trim().toUpperCase();
if (!code) return;
}
start('classic'); // the waiting court is classic either way; a death room swaps it on pairing
// the host's court rides the room too (server.py) — the guest plays where the host plays
if (startNetVs()) netConnect(code, death, picked);
});
return b;
}
// (NETPLAY is the exception and has a screen of its own — see THE LOBBY below.)
function buildSub(top) {
modeSub.innerHTML = '';
const t = top || topOf(pickedMode);
document.getElementById('subTitle').textContent = t.diffs ? 'DIFFICULTY' : (t.name || '');
if (t.net) {
modeSub.appendChild(netChip('HOST — CLASSIC TENNIS', false));
modeSub.appendChild(netChip('HOST — DEATH MATCH', false, true));
modeSub.appendChild(netChip('JOIN WITH A CODE', true));
return;
}
if (t.diffs) for (const d of DIFFS) {
const b = document.createElement('button');
b.className = 'chip';
@@ -161,9 +133,9 @@ function buildTops() {
for (const t of MENU) {
const b = banner(t.mode ? modeOf(t.mode) : t, 'mode', topOf(pickedMode) === t);
b.addEventListener('click', () => {
// the netplay poster deals HOST/JOIN and never touches pickedMode — it is not a run mode
// the stats screen or the summary could name, and either chip starts the game itself
if (t.net) { buildSub(t); go('modesub'); return; }
// the netplay poster opens THE LOBBY and never touches pickedMode — it is not a run mode
// the stats screen or the summary could name, and the lobby starts the game itself
if (t.net) { vsOpen(); return; }
// a group remembers which child you were on; entering it fresh lands on its first card
pickMode(t.mode || (t.kids.includes(pickedMode) ? pickedMode : t.kids[0]));
buildSub();
@@ -176,6 +148,75 @@ function buildTops() {
buildTops();
buildSub();
// ---- THE LOBBY. Oliver, 2026-08-15: *"multiplayer should be easier to setup — a share with link
// next to the code; entering the code is done in some plain js confirm dialog thingy; somewhere
// when hosting or also when joining the player should name themselves."* All three are one screen.
//
// WHY IT IS A MENU SCREEN AND NOT A COURT. Hosting used to drop you straight onto an empty court
// and show the code in a canvas modal — which cannot carry a SHARE button, cannot be typed into,
// and turns a mistyped code into a CONNECTION LOST card over a match that never started. The
// socket is now opened from the menu and the game stays parked behind the overlay until the room
// actually fills: `netHooks.paired` is what takes the court, one frame before netStart writes to
// it. Everything else about the protocol is untouched.
//
// THE NAME IS THE WORLD BOARD'S NAME (stats.saveName). One name per player, wherever it shows.
const vsPick = document.getElementById('vsPick'), vsWait = document.getElementById('vsWait');
const vsName = document.getElementById('vsName'), vsCode = document.getElementById('vsCode');
const vsSay = (id, text) => { document.getElementById(id).textContent = text; };
// the link IS the invitation: the joiner's phone opens the lobby with the code already in the field
const vsLink = code => location.origin + location.pathname + '#net-' + code;
function vsOpen(code = '') {
vsName.value = stats.savedName();
vsCode.value = code;
vsPick.hidden = false; vsWait.hidden = true;
vsSay('vsErr', '');
go('vs');
}
// join === null hosts; `death` is the host's one decision and rides the room to both phones
function vsGo(join, death = false) {
stats.saveName(vsName.value); // whatever is in the field is who the other phone sees
const code = join ? stats.tidyName(vsCode.value).replace(/[^A-Z0-9]/g, '') : null;
if (join && code.length < 4) { vsSay('vsErr', 'THE CODE IS FOUR LETTERS'); return; }
if (!startNetVs()) return; // the court-ownership contract, same as every other mode
netConnect(code, death, picked); // the host's court rides the room (server.py)
vsPick.hidden = true; vsWait.hidden = false;
vsSay('vsWHdr', join ? 'JOINING' : 'ROOM CODE');
vsSay('vsBig', join ? code : '····');
vsSay('vsMsg', join ? 'KNOCKING ON THEIR DOOR' : 'WAITING FOR THEM TO JOIN');
document.getElementById('vsShare').hidden = !!join; // nothing to share on the way in
}
document.getElementById('vsHostC').addEventListener('click', () => vsGo(null));
document.getElementById('vsHostD').addEventListener('click', () => vsGo(null, true));
document.getElementById('vsJoin').addEventListener('click', () => vsGo(true));
vsCode.addEventListener('keydown', e => { if (e.key === 'Enter') vsGo(true); });
// SHARE, then COPY. navigator.share is the phone's own sheet (WhatsApp, Signal, wherever the
// friend actually is) and is exactly the "easier setup" — the clipboard is the desktop fallback,
// and the code stays on screen for the two of them in the same room reading it aloud.
document.getElementById('vsShare').addEventListener('click', async () => {
const url = vsLink(G.net && G.net.code || '');
try {
if (navigator.share) await navigator.share({ title: 'SPLATTER TENNIS', text: 'COME AND PLAY ME', url });
else { await navigator.clipboard.writeText(url); vsSay('vsMsg', 'LINK COPIED — SEND IT TO THEM'); }
} catch { /* a cancelled share sheet is not an error */ }
});
const vsQuit = () => { endNetVs(); vsOpen(); }; // a room you walked out of must give its socket back
document.getElementById('vsCancel').addEventListener('click', vsQuit);
document.getElementById('vsBack').addEventListener('click', () => {
if (!vsWait.hidden) return vsQuit(); // waiting: the chevron cancels rather than leaves
endNetVs(); goMode();
});
// the three hooks net.js calls back through — all of them no-ops once the court has been taken
netHooks.paired = () => start('classic'); // start() refuses when the menu is already down (URL entry)
netHooks.room = code => { vsSay('vsBig', code); vsSay('vsMsg', 'WAITING FOR THEM TO JOIN'); };
netHooks.lost = text => {
if (!menuUp()) return false; // mid-match: the court's own card says it
endNetVs();
vsPick.hidden = false; vsWait.hidden = true;
vsSay('vsErr', text);
return true;
};
// --- tour tiles, built from the table. Same "tap selects, SERVE UP starts" rule as the court rows —
// the setup screen picks WHO and WHERE, and nothing on it commits you.
const tourList = document.getElementById('tours');
@@ -733,15 +774,10 @@ if (location.hash === '#drill') setTimeout(() => { start(null); intoSplatter();
if (location.hash === '#match') setTimeout(() => { start(null); intoSplatter(); startMatch(); }, 700);
if (location.hash === '#cup') setTimeout(() => { start(null); intoSplatter(); startMatch(true); }, 700);
if (location.hash === '#bench') setTimeout(() => { start(null); intoSplatter(); startBench(); }, 700);
// #net hosts classic, #netd hosts a death match, #net-ABCD joins — the netplay doors from a URL
if (location.hash === '#netd') setTimeout(() => {
start('classic');
if (startNetVs()) netConnect(null, true, picked);
}, 700);
else if (location.hash.startsWith('#net')) setTimeout(() => {
start('classic');
if (startNetVs()) netConnect(location.hash.slice(5).toUpperCase() || null, false, picked);
}, 700);
// #net opens the lobby, #net-ABCD opens it with a friend's code already in the field — that link
// IS what the SHARE button sends, so the invited phone lands one tap from the court (the tap is
// not ceremony: it is where they see, and can change, the name the other phone will call them).
if (location.hash.startsWith('#net')) setTimeout(() => vsOpen(location.hash.slice(5).toUpperCase()), 700);
requestAnimationFrame(frame);
+33 -16
View File
@@ -19,14 +19,19 @@
// 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, showModal, tossBall } from './gameplay.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 here, so this module never has to know how a splatter court is
// entered (the transition hack lives in main). Headless tests leave it empty and net falls back to
// the minimal direct entry, which is exactly what the suite's own freshSplatter does.
export const netHooks = { intoSplatter: null };
// 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
@@ -115,10 +120,16 @@ function netSync(m) {
}
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, 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 });
// 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
@@ -159,6 +170,9 @@ function netDead(text) {
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 };
}
@@ -166,12 +180,12 @@ 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'] });
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);
else if (m.t === 'gone') netDead('NO SUCH ROOM — RELOAD AND HOST ONE');
// '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);
@@ -179,13 +193,16 @@ export function netMsg(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. 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 — the match still plays.
// 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',
};
}
+5 -1
View File
@@ -2519,7 +2519,11 @@ export function render() {
// ...and the match names the thing across the net rather than calling it THEM. Eight characters,
// which is the name slot — see `short` on the opponent rows in config.
const foe = G.match && zombies.find(z => z.variant === 'r' && z.foe);
const names = G.match ? ['YOU', (foe && foe.foe.short) || 'THEM']
// NETPLAY NAMES THE PERSON, in either mode — the friend typed it into the lobby and it crossed
// on the look message. FRIEND until it lands (or an older build never sends one), never CPU:
// there is no CPU in this mode and the board saying so was the netplay prototype's last lie.
const names = G.net ? ['YOU', (G.net.look && G.net.look.name) || 'FRIEND']
: G.match ? ['YOU', (foe && foe.foe.short) || 'THEM']
: splat ? ['YOU', 'THEM'] : ['YOU', 'CPU'];
for (let i = 0; i < 2 && !benchOn; i++) {
const cy = 12 + i * 18, svr = (score.server === 'P') === (i === 0); // rows aligned to the art's two bands
+8 -2
View File
@@ -208,13 +208,19 @@ export async function board(mode = MODE_GORE) {
// appear on the board.
export const tidyName = s =>
String(s).toUpperCase().replace(/[^A-Z0-9 ._-]/g, '').trim().slice(0, 12);
// ...and the one place it is remembered. The world board and the netplay lobby ask for the same
// name and must not keep two of them: whoever you signed the board as is who the other phone sees.
export const saveName = s => {
const name = tidyName(s);
try { localStorage.setItem(NAME_KEY, name); } catch {} // type it once, ever
return name;
};
// `k` is the BOARD's number and `g` its second column — what those mean is the mode's business
// (BOARDS), and the server only ever sorts them. `m` is which board the row goes on.
export async function submit(rawName, k, g, mode = MODE_GORE) {
const name = tidyName(rawName);
const name = saveName(rawName);
if (!name) throw new Error('empty name');
try { localStorage.setItem(NAME_KEY, name); } catch {} // type it once, ever
const r = await fetch('api/scores', { method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ n: name, k, g, m: mode, id: myId() }) });