fix(frontend): scope player identity to a tab, not the browser

Two tabs of the same browser share localStorage, so both got the same
persisted playerId and stepped on each other: matchmaking's waiting
queue is keyed by playerId and dedupes a second queue:join as a no-op,
so two tabs searching never both count as waiting; and re-identifying
with the same playerId hijacks the ws registry's socket binding, tearing
down the first tab's connection (surfacing as room:join replying 'room
already has two players' once its creator's socket is dead).

Move playerId/name from localStorage to sessionStorage: still stable
across reloads within a tab (reconnect-by-refresh keeps the same
identity), but each new tab gets a fresh UUID, so two tabs of one
browser behave like two separate players. Not IP-related; no such
restriction existed.
This commit is contained in:
Gabriel Franco 2026-09-13 19:52:32 -03:00
parent 16d36328ec
commit 42d1732816
2 changed files with 15 additions and 10 deletions

View file

@ -25,8 +25,10 @@ message-specific fields — no envelope wrapper, no request/response ids.
There is no account system and none is planned — adding auth is out of There is no account system and none is planned — adding auth is out of
scope for multiplayer entirely. Instead, the client generates a scope for multiplayer entirely. Instead, the client generates a
`playerId` (a `crypto.randomUUID()` v4 string) the first time it loads, `playerId` (a `crypto.randomUUID()` v4 string) the first time a tab
persists it in `localStorage`, and reuses it on every future visit. The loads, persists it in `sessionStorage`, and reuses it for the lifetime
of that tab (a reload keeps the same identity; a new tab gets a new
one, so two tabs of the same browser act as two distinct players). The
player also picks/keeps a display `name` (also persisted client-side, no player also picks/keeps a display `name` (also persisted client-side, no
server-side uniqueness check). The very first message a client sends server-side uniqueness check). The very first message a client sends
after the socket opens registers both with the server: after the socket opens registers both with the server:

View file

@ -1,18 +1,21 @@
// Per-browser player identity. There is no account system (see // Per-tab player identity. There is no account system (see
// backend/src/PROTOCOL.md's "Identity" section) — just a stable UUID // backend/src/PROTOCOL.md's "Identity" section) — just a stable UUID
// generated once and reused, plus a freely editable display name. Both // generated once and reused, plus a freely editable display name. Both
// are persisted in localStorage so they survive reloads and reconnects. // are persisted in sessionStorage so they survive reloads/reconnects
// within a tab but don't leak across tabs — two tabs of the same
// browser (e.g. testing matchmaking/private rooms locally) are two
// distinct players, the same as two separate browsers would be.
const PLAYER_ID_KEY = 'themeGuess.playerId'; const PLAYER_ID_KEY = 'themeGuess.playerId';
const PLAYER_NAME_KEY = 'themeGuess.playerName'; const PLAYER_NAME_KEY = 'themeGuess.playerName';
/** Returns the persisted `playerId`, generating and storing a new /** Returns the persisted `playerId`, generating and storing a new
* `crypto.randomUUID()` the first time this browser is seen. */ * `crypto.randomUUID()` the first time this tab is seen. */
export function getOrCreatePlayerId(): string { export function getOrCreatePlayerId(): string {
const existing = localStorage.getItem(PLAYER_ID_KEY); const existing = sessionStorage.getItem(PLAYER_ID_KEY);
if (existing) return existing; if (existing) return existing;
const created = crypto.randomUUID(); const created = crypto.randomUUID();
localStorage.setItem(PLAYER_ID_KEY, created); sessionStorage.setItem(PLAYER_ID_KEY, created);
return created; return created;
} }
@ -20,14 +23,14 @@ export function getOrCreatePlayerId(): string {
* a generated default derived from the player's id so `identify` never * a generated default derived from the player's id so `identify` never
* has to send an empty `name`. */ * has to send an empty `name`. */
export function getPlayerName(): string { export function getPlayerName(): string {
const existing = localStorage.getItem(PLAYER_NAME_KEY); const existing = sessionStorage.getItem(PLAYER_NAME_KEY);
if (existing) return existing; if (existing) return existing;
const fallback = `Player${getOrCreatePlayerId().slice(0, 4)}`; const fallback = `Player${getOrCreatePlayerId().slice(0, 4)}`;
localStorage.setItem(PLAYER_NAME_KEY, fallback); sessionStorage.setItem(PLAYER_NAME_KEY, fallback);
return fallback; return fallback;
} }
/** Overwrites the persisted display name. */ /** Overwrites the persisted display name. */
export function setPlayerName(name: string): void { export function setPlayerName(name: string): void {
localStorage.setItem(PLAYER_NAME_KEY, name); sessionStorage.setItem(PLAYER_NAME_KEY, name);
} }