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

+42 -2
View File
@@ -414,6 +414,46 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu
`RETURNED`, `DOUBLE KILL`) is a footnote to something you already watched happen. Sharing the loud
FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone.
## Session log: THE NETPLAY LOBBY — a link to send, a code to type, a name to be (2026-08-15)
Oliver: *"multiplayer should be easier to setup — a share with link next to the code; also entering
the code is done in some plain js confirm dialog thingy, this could be embedded more nicely;
somewhere when hosting or also when joining the player should name themselves."* All three are one
screen (`#vs`, `index.html` + THE LOBBY in `main.js`); the protocol underneath is untouched.
- **THE LOBBY IS A MENU SCREEN, and that is the whole structural change.** Hosting used to call
`start('classic')` first and show the room code in a CANVAS modal over an empty court — which
cannot carry a share button, cannot be typed into, and turned a mistyped code into a CONNECTION
LOST card over a match that never began. The socket is opened from the menu now and the game
stays parked behind the overlay until the room actually fills. **`netHooks.paired` is what takes
the court**, called at the top of `netStart` — one line before everything that writes to it, so
the kit/tour selection `start()` performs is already done when the `look` message reads it.
Two more hooks join `intoSplatter`: `room` (the code landed) and `lost` (returns true when the
lobby said so itself, which is what suppresses the card). Headless leaves them null.
- **SHARE THE LINK, then the code.** `navigator.share` is the phone's own sheet — the friend is in
WhatsApp, not in a room reading letters aloud — with `clipboard.writeText` as the desktop
fallback and the four letters still big on screen for when they are in the same room. The link is
the URL door that already existed (`#net-ABCD`): it opens the lobby with the code prefilled,
ONE tap from the court. Deliberately not an auto-join — that tap is where the joiner sees, and
can change, the name the other phone will call them.
- **ONE NAME PER PLAYER.** The field is prefilled from `stats.savedName()` and written back through
the new `stats.saveName` (which `submit` now shares), so the world board's name and the netplay
name cannot become two things. It crosses on the EXISTING `look` message as `n` — no new message
type, and an older build simply sends none. **The scoreboard names the person** (`render`, the
names block) in both netplay modes: FRIEND until the look lands, never CPU. A stranger's string
lands in an 8-character slot, so it is normalised at the RECEIVING end through the same
`tidyName` the board and `server.py`'s `clean()` agree on.
- **Failure is a typo now, not a card.** A short code never opens a socket; `gone` puts
NO SUCH ROOM — CHECK THE CODE over the picker with the field still there. CANCEL and the header
chevron both `endNetVs`, so a room you walked out of gives its socket back.
- Verified through the real relay in two panes: host → share link → join → both take the court,
host serves, the board reads YOU / GUEST, and the death-match room rebuilds both courts the same
way (blood pips, the friend as the living opponent, no roster body). 96 tests.
- Smaller: the JOIN button is the crimson plate stretched to the field's own height (`#vs .joinrow
.btn` — a bare `.joinrow .btn` loses to `#vs .btn`'s margin, and a margin on a stretched flex
item is exactly the 8px it was coming up short by); `.tin` is now the one typed-field look, worn
by the name prompt and both lobby fields.
## Session log: gore opens in the gore, the theme comes back, three boards (2026-08-15, same day)
Oliver's finetuning pass on the batch below.
@@ -678,8 +718,8 @@ agree on the same point from opposite perspectives). 73 tests.
serve and rally AI are gated off, `restartRun` closes the socket.
- **Honest limits, all deliberate (ponytail)**: NO reconnect-and-resume — a dropped radio is a
CONNECTION LOST card and a reload (the recorded plan calls reconnection "the bulk of the real
work"; a prototype must not start there). prompt() is the join UI. The scoreboard still says
CPU. A return snaps the ball to the mirrored strike (~latency of drift eaten in one hop; the
work"; a prototype must not start there). ~~prompt() is the join UI. The scoreboard still says
CPU.~~ *(both gone — see THE NETPLAY LOBBY above.)* A return snaps the ball to the mirrored strike (~latency of drift eaten in one hop; the
flight swallows it). Runs bank as classic runs. The wound-decay hook (losing IS zombification)
is the recorded next step once the feel survives a two-phone playtest.
- **Test on staging with two phones** — the relay deploys with the ordinary push, nothing to
+76 -12
View File
@@ -155,7 +155,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
/* ONE overlay, four panels, swapped by #intro[data-stage]. Keeping the backdrop and the card frame
alive across every screen is the point — a screen that unmounts the whole overlay flashes the
court through between menus. */
#splash, #setup, #stats, #ranks, #newbest, #secret, #bosses, #stmode, #stsub, #bracket { display: none; }
#splash, #setup, #stats, #ranks, #newbest, #secret, #bosses, #stmode, #stsub, #bracket, #vs { display: none; }
#intro[data-stage="splash"] #splash { display: flex; flex-direction: column; flex: 1; }
/* flex COLUMN, not block: it is what lets the footer's margin-top:auto reach the bottom of a
screen whose content does not fill it */
@@ -167,6 +167,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
#intro[data-stage="secret"] #secret,
#intro[data-stage="bosses"] #bosses,
#intro[data-stage="bracket"] #bracket,
#intro[data-stage="vs"] #vs,
#intro[data-stage="newbest"] #newbest { display: flex; flex-direction: column; flex: 1;
animation: drop 0.3s ease-out; }
/* the overlay is HIDDEN during play, not removed: a restart puts it straight back, and the
@@ -400,21 +401,53 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
.rkrow span { flex: 1; min-width: 0; font: 400 11px Silkscreen, monospace; color: var(--cream);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rkrow i { font: 400 11px Silkscreen, monospace; font-style: normal; color: var(--dim); }
/* ---- the name prompt. It is the only screen in the game that asks for typing, so the field is
deliberately big, centred and already focused: on a phone this appears with the keyboard
rising under it and there is no room for a second tap to find the input. */
/* ---- typed fields. Two screens ask for typing — the name prompt and the netplay lobby — and
both want the same field: big, centred, acid on the panel, no rounded corners anywhere near it.
One class, two size overrides. */
.cent { text-align: center; }
.tin { display: block; width: 100%; box-sizing: border-box; margin: 0 auto;
padding: 12px 10px; background: var(--solid); border: 2px solid var(--line);
border-radius: 0; color: var(--acid); font: 700 16px Silkscreen, monospace;
letter-spacing: 3px; text-align: center; text-transform: uppercase;
-webkit-appearance: none; -webkit-tap-highlight-color: transparent; }
.tin:focus { outline: none; border-color: var(--acid); }
.tin::placeholder { color: var(--dim); letter-spacing: 2px; font-size: 12px; }
#newbest h2 { font-size: 30px; margin-bottom: 6px; }
#nbScore { margin: 0 0 22px; font: 400 11px Silkscreen, monospace; color: var(--dim);
letter-spacing: 1px; }
#nbScore b { color: var(--acid); font-weight: 400; }
#nbName { display: block; width: 100%; box-sizing: border-box; margin: 0 auto 10px;
padding: 14px 10px; background: var(--solid); border: 2px solid var(--line);
border-radius: 0; color: var(--acid); font: 700 20px Silkscreen, monospace;
letter-spacing: 3px; text-align: center; text-transform: uppercase;
-webkit-appearance: none; -webkit-tap-highlight-color: transparent; }
#nbName:focus { outline: none; border-color: var(--acid); }
#nbName::placeholder { color: var(--dim); letter-spacing: 2px; font-size: 14px; }
#nbName { margin-bottom: 10px; padding: 14px 10px; font-size: 20px; }
#nbName::placeholder { font-size: 14px; }
/* ---- THE NETPLAY LOBBY. Everything the mode needs before there is a court: who you are, the
two ways to host, and the friend's code. The room you are waiting in replaces the picker in
place (#vsWait), so the code and the SHARE button stand where the buttons were. */
#vsErr:empty { display: none; }
#vsErr { margin: 10px 0 0; padding: 8px; border: 2px solid #6e2a24; background: rgba(110,42,36,0.22);
font: 400 10px Silkscreen, monospace; color: var(--bad); letter-spacing: 1px;
text-align: center; }
#vs .btn { margin-bottom: 8px; }
/* the room takes the whole screen the picker had: the code floats in the middle band and CANCEL
sits under your thumb, same three-band anchoring as every other screen */
#vsWait { display: flex; flex-direction: column; flex: 1; }
#vsWait[hidden] { display: none; }
.joinrow { display: flex; gap: 8px; align-items: stretch; }
.joinrow .tin { flex: 1; letter-spacing: 8px; }
/* JOIN is the act, not a label beside a box: the crimson plate every primary button in the game
wears, and NO padding of its own — the row stretches it to exactly the field's height, which
its own 13px padding could only ever approximate. */
/* #vs .btn's margin-bottom outranks a bare .joinrow .btn — and a margin on a stretched flex item
is exactly the 8px the button was coming up short by */
#vs .joinrow .btn { flex: none; width: 104px; margin: 0; padding: 0 6px; background: var(--red);
border-color: #69201c; color: #f6e7dc; font: 400 22px Bangers, cursive;
letter-spacing: 2px; }
/* the code is the biggest thing on the screen — it is read aloud across a room as often as it is
shared, and a four-letter code you have to squint at is the whole friction being removed */
#vsBig { margin: 4px 0 20px; font: 700 42px Silkscreen, monospace; color: var(--acid);
letter-spacing: 10px; text-indent: 10px; }
#vsMsg { margin-top: 12px; }
/* the h3 rows are label-left/hint-right everywhere else; this one has no hint and sits over a
centred code, so it centres too (text-align cannot reach it — the row is a flex box) */
#vsWHdr { justify-content: center; }
</style>
</head>
<body>
@@ -509,6 +542,37 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
<button class="btn big" id="cupGo" disabled>ENTER THE DRAW</button>
</div>
</div>
<!-- VS A FRIEND. The whole lobby: your name (the world board's name — one name per player),
the host's one decision, and the friend's code. #vsWait replaces the picker in place once
a socket is open, so the room code and its SHARE button stand where the buttons were and
the game stays parked behind this overlay until the room fills (main.js, THE LOBBY). -->
<div id="vs">
<div class="hdr"><button class="chev" id="vsBack">&lsaquo;</button><h2>VS A FRIEND</h2></div>
<div id="vsPick">
<h3>01 · YOUR NAME<span>ON THEIR SCOREBOARD</span></h3>
<input id="vsName" class="tin" maxlength="12" placeholder="YOUR NAME" autocomplete="off"
autocapitalize="characters" spellcheck="false">
<h3>02 · HOST A MATCH<span>THEY COME TO YOU</span></h3>
<button class="btn" id="vsHostC">CLASSIC TENNIS</button>
<button class="btn" id="vsHostD">DEATH MATCH</button>
<h3>OR JOIN THEIRS<span>WITH THEIR CODE</span></h3>
<div class="joinrow">
<input id="vsCode" class="tin" maxlength="4" placeholder="CODE" autocomplete="off"
autocapitalize="characters" spellcheck="false">
<button class="btn" id="vsJoin">JOIN</button>
</div>
<p id="vsErr"></p>
</div>
<div id="vsWait" hidden>
<div class="mid cent">
<h3 id="vsWHdr" class="cent">ROOM CODE</h3>
<p id="vsBig">····</p>
<button class="btn big" id="vsShare">SHARE THE LINK</button>
<p class="none cent" id="vsMsg">WAITING FOR THEM TO JOIN</p>
</div>
<div class="foot bare"><button class="btn" id="vsCancel">CANCEL</button></div>
</div>
</div>
<!-- BODY COUNT. Every row in here is built by stats.js from the run records in localStorage. -->
<div id="stats">
<div class="hdr"><button class="chev" id="stBack">&lsaquo;</button><h2 id="stTitle">BODY COUNT</h2></div>
@@ -581,6 +645,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
</div>
</div>
</div>
<script type="module" src="src/main.js?v=265"></script>
<script type="module" src="src/main.js?v=266"></script>
</body>
</html>
+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() }) });
+13
View File
@@ -1645,6 +1645,19 @@ test('netplay: your look goes over the wire on pairing, and theirs comes back',
state.selectKit(cfg.KITS[0].id); state.selectSkin(cfg.SKINS[0].id); // leave the table as found
});
// THE NAME RIDES THE SAME MESSAGE — the lobby's one typed field, on the other phone's scoreboard.
// It is a stranger's string landing in an 8-character slot, so it is normalised at the receiving
// end (the same tidyName the world board and server.py's clean() agree on), never as sent.
test('netplay: the friend names themselves, and the name lands tidy in the board\'s slot', () => {
freshNet('A');
const look = netTest.sent.find(m => m.t === 'look');
assert.equal(look.n, statsMod.savedName(), 'this phone announces the name it remembers');
netMsg({ t: 'look', k: 'red', s: 'pale', n: ' <b>bob</b>ranathan ' });
assert.equal(G.net.look.name, 'BBOBBRAN', 'upper, stripped and cut to the eight-character slot');
netMsg({ t: 'look', k: 'red', s: 'pale' }); // an older build sends no name at all
assert.equal(G.net.look.name, 'FRIEND', '...and an unnamed opponent is FRIEND, never CPU');
});
test('every kit and skin in the tables is a live, distinct pick', () => {
for (const table of [cfg.KITS, cfg.SKINS]) {
assert.equal(new Set(table.map(e => e.id)).size, table.length, 'ids are unique');