fix(frontend,backend): online scoring bug, mutual reveal vote, theme preview timing

Scoring bug (priority fix): MultiplayerMatch/BotMatch add a `hidden`
class to the solo result panel (#solo-score-meter-wrap, #score-verdict,
#score-breakdown, #compare-toggle) once a networked/bot round's real
two-column scoreboard takes over, but no CSS rule ever paired `.hidden`
with those selectors (every other hideable element in this codebase has
its own scoped rule) -- the class was inert, so the solo panel stayed
visible forever, stacked above the mp-scoreboard, making both look like
they showed the same/wrong numbers. Added the missing scoped rules.

Reveal button (online play): clicking Reveal Match used to run
ThemeGuessGame's own instant local reveal() even in a live multiplayer
round -- unrelated to the server, and the direct trigger of the above
scoring bug. Added a revealOverride hook on ThemeGuessGame; while a
multiplayer round is live, MultiplayerMatch routes the click through a
new round:revealVote message instead. The round now only ends early
once *both* players vote (server tracks votes per session and reveals
immediately on a mutual vote); a single vote relays round:revealVoteUpdate
to the opponent, and the voter sees "Waiting for opponent to vote for
reveal..." below the button.

Theme preview timing: matchmaking and private-room rounds never ran the
10s theme-preview flash solo/bot rounds already have, so a round's real
colors never appeared before painting started. Added it to both flows
(ui/matchmakingFlow.ts, ui/privateRoomFlow.ts, including every match in
a private room's up-to-5-match series, not just the first). The
backend's round:start.endsAt now bakes in a matching 10s PREVIEW_MS
head start (session.js) so the preview never eats into actual painting
time.

Private room results view: a private room's next match used to start
within milliseconds of the previous one's reveal, cutting the result
modal off almost instantly. room.js now delays round:nextMatch (and the
series-ending room:closed) by RESULT_VIEW_MS (10s) after round:reveal so
both players actually get to look at a match's result.

Theme grid / vote UI: the theme-vote modal's grid was missing the
theme-grid class that actually applies the 2-column CSS grid (dead CSS
already anticipated the combination), so vote theme cards rendered in
whatever the browser's default block flow was. Added the class, and
added a distinct (pink, dashed outline) opponent-pick highlight to
ui/themeGrid.ts so the opponent's live vote pick is visually distinct
from the local player's own selection, keeping the existing 'Opponent
chose <theme>' text.

PROTOCOL.md updated for all wire-visible changes: endsAt formula,
round:revealVote/round:revealVoteUpdate, and the round:nextMatch/
room:closed timing.
This commit is contained in:
Gabriel Franco 2026-09-13 20:36:52 -03:00
parent 42d1732816
commit 7a6df57ad0
13 changed files with 285 additions and 40 deletions

View file

@ -68,8 +68,9 @@ stateDiagram-v2
Painting --> Painting: round:progress (either direction, repeatable) Painting --> Painting: round:progress (either direction, repeatable)
Painting --> Warning: round:timeWarning (t = endsAt - 20s) Painting --> Warning: round:timeWarning (t = endsAt - 20s)
Warning --> Warning: round:progress Warning --> Warning: round:progress
Warning --> Submitted: round:submit (per player, 0-2 times) Painting --> Submitted: round:submit / round:revealVote (per player, 0-2 times)
Submitted --> Reveal: endsAt reached Warning --> Submitted: round:submit / round:revealVote (per player, 0-2 times)
Submitted --> Reveal: endsAt reached, or both round:revealVote
Reveal --> [*]: round:reveal Reveal --> [*]: round:reveal
``` ```
@ -83,9 +84,16 @@ stateDiagram-v2
"endsAt": 1757500000000 // epoch ms; server-authoritative round-end deadline "endsAt": 1757500000000 // epoch ms; server-authoritative round-end deadline
} }
``` ```
`endsAt` is computed server-side (`now + timeMode * 60_000`) and is the `endsAt` is computed server-side (`now + PREVIEW_MS + timeMode * 60_000`,
single source of truth for round timing; clients render a countdown `PREVIEW_MS` = 10s, `backend/src/game/session.js`) and is the single
from it but never decide when the round ends themselves. source of truth for round timing. The `PREVIEW_MS` head start is
deliberate, not latency slack: both clients spend it flashing
`themeId`'s real colors (`ui/previewFlow.ts`'s `runThemePreview`, same
10s window as solo/bot rounds) before the board unlocks and the
countdown is even shown, so painting time always equals the full
`timeMode` regardless of that shared preview. Clients render a
countdown from `endsAt` but never decide when the round ends
themselves.
- **`round:progress`** — client -> server -> opponent, fired every time a - **`round:progress`** — client -> server -> opponent, fired every time a
player assigns a color to a category while painting. player assigns a color to a category while painting.
@ -133,10 +141,30 @@ stateDiagram-v2
omitted and the receiving client treats it the same as an omitted and the receiving client treats it the same as an
all-`null` map). all-`null` map).
- **`round:reveal`** — server -> both players, sent only once `endsAt` - **`round:revealVote`** — client -> server, the Reveal button's click
has passed (never early, even if both players already submitted) so while a round is still live (only enabled once every category is
neither client can infer the opponent's picks before the round is painted — see `frontend/src/game/ThemeGuessGame.ts`'s `revealBtn`
officially over. gating). Same shape and same "last one wins" semantics as
`round:submit`, and is in fact stored exactly like one; the *only*
extra effect is that once **both** players have sent a
`round:revealVote` for the round, it ends immediately — `round:reveal`
fires right away instead of waiting for `endsAt`.
```jsonc
{ "type": "round:revealVote", "colors": { "background": "#1a1b26", ... } }
```
- **`round:revealVoteUpdate`** — server -> the *other* player, sent when
the sender is the first (not yet both) of the two to `round:revealVote`
this round, so that player's client can nudge them ("your opponent
wants to reveal") to do the same.
```jsonc
{ "type": "round:revealVoteUpdate" }
```
- **`round:reveal`** — server -> both players, sent once `endsAt` has
passed, or immediately if both players `round:revealVote` first —
never early on a *single* player's submission or vote, so neither
client can infer the opponent's picks before the round is officially
over for both.
```jsonc ```jsonc
{ {
"type": "round:reveal", "type": "round:reveal",
@ -155,7 +183,12 @@ Private rooms additionally get:
- **`round:nextMatch`** — server -> both players, sent after a - **`round:nextMatch`** — server -> both players, sent after a
`round:reveal` in a private room if the series hasn't hit its 5-match `round:reveal` in a private room if the series hasn't hit its 5-match
cap, immediately followed by the next `round:start`. cap, `RESULT_VIEW_MS` (10s, `backend/src/rooms/room.js`) after that
`round:reveal` — giving both players a fixed window to actually look
at the match's result before the room moves on — immediately followed
by the next `round:start`. The series-ending match instead delays
`room:closed` (`reason: "matchLimit"`, see below) by the same
`RESULT_VIEW_MS` for the same reason.
```jsonc ```jsonc
{ "type": "round:nextMatch", "match": 3 } // 1-5, the match about to start { "type": "round:nextMatch", "match": 3 } // 1-5, the match about to start
``` ```
@ -266,9 +299,11 @@ stateDiagram-v2
``` ```
- `quit` — `room:playerLeft` just fired; a player quit or disconnected - `quit` — `room:playerLeft` just fired; a player quit or disconnected
before the 5-match series completed. before the 5-match series completed.
- `matchLimit` — match 5 of 5's `round:reveal` was just delivered and - `matchLimit` — match 5 of 5's `round:reveal` was delivered and the
the series cap was reached; the server closes the room on its own, series cap was reached; sent `RESULT_VIEW_MS` after that
no client action required. `round:reveal` (same result-viewing window `round:nextMatch` gets —
see above) and the server closes the room on its own, no client
action required.
- `finished` — both players are done looking at the last result and - `finished` — both players are done looking at the last result and
one of them explicitly leaves the room via the post-game UI (a one of them explicitly leaves the room via the post-game UI (a
normal, non-quit exit after `matchLimit` would otherwise leave the normal, non-quit exit after `matchLimit` would otherwise leave the

View file

@ -22,6 +22,13 @@ const WARNING_OFFSET_MS = 20_000;
// server reveals with whatever it has (empty color map for a client that // server reveals with whatever it has (empty color map for a client that
// never submitted). // never submitted).
const SUBMIT_GRACE_MS = 3_000; const SUBMIT_GRACE_MS = 3_000;
// PROTOCOL.md "round:start": every round's countdown (`endsAt`) starts
// this many ms after `round:start` is sent, giving both clients a fixed
// window to run their local theme-preview flash before painting is even
// possible — baked into `endsAt` itself (not a separate delayed send) so
// the server stays the single source of truth for round timing that
// `PROTOCOL.md` promises.
const PREVIEW_MS = 10_000;
const HEX_COLOR_PATTERN = /^#?[0-9a-fA-F]{3,8}$/; const HEX_COLOR_PATTERN = /^#?[0-9a-fA-F]{3,8}$/;
@ -120,7 +127,7 @@ export function createSession({ playerA, playerB, timeMode, themeId, snippetInde
? snippetIndex ? snippetIndex
: Math.floor(Math.random() * SNIPPET_CATALOG_LENGTH); : Math.floor(Math.random() * SNIPPET_CATALOG_LENGTH);
const endsAt = Date.now() + timeMode * 60_000; const endsAt = Date.now() + PREVIEW_MS + timeMode * 60_000;
const session = { const session = {
playerA, playerA,
@ -131,6 +138,10 @@ export function createSession({ playerA, playerB, timeMode, themeId, snippetInde
endsAt, endsAt,
onEnd, onEnd,
submissions: new Map(), submissions: new Map(),
// playerIds who've clicked "Reveal Match" and asked to end the round
// early — see the `round:revealVote` handler below. Reset per
// session; a mutual vote reveals immediately, bypassing endsAt.
revealVotes: new Set(),
ended: false, ended: false,
roundEnded: false, roundEnded: false,
warningTimer: null, warningTimer: null,
@ -187,6 +198,33 @@ registerHandler('round:submit', (socket, message) => {
tryReveal(session); tryReveal(session);
}); });
// PROTOCOL.md "round:revealVote": lets both players agree to end the
// round before endsAt. Carries the same full colors shape as
// round:submit (the Reveal button is only enabled once every category
// is painted, so this is always a real final guess, not a partial one)
// and is treated identically to a submission; the only difference is
// that a *mutual* vote reveals immediately instead of waiting for
// endsAt/tryReveal's grace window.
registerHandler('round:revealVote', (socket, message) => {
const playerId = getPlayerId(socket);
const session = sessionsByPlayerId.get(playerId);
if (!session || session.ended) return;
const colors =
message.colors && typeof message.colors === 'object' && !Array.isArray(message.colors) ? message.colors : {};
session.submissions.set(playerId, colors);
session.revealVotes.add(playerId);
if (session.revealVotes.size >= 2) {
session.roundEnded = true;
clearSessionTimers(session);
revealSession(session);
return;
}
sendTo(otherPlayer(session, playerId), { type: 'round:revealVoteUpdate' });
});
registerHandler('player:quit', (socket) => { registerHandler('player:quit', (socket) => {
const playerId = getPlayerId(socket); const playerId = getPlayerId(socket);
const session = sessionsByPlayerId.get(playerId); const session = sessionsByPlayerId.get(playerId);

View file

@ -20,6 +20,13 @@ import { startVote } from './themeVote.js';
const MAX_MATCHES = 5; const MAX_MATCHES = 5;
const CODE_MIN = 100000; const CODE_MIN = 100000;
const CODE_MAX = 999999; const CODE_MAX = 999999;
// How long a private-room result screen stays up (both `round:nextMatch`
// and, for the series-ending match, `room:closed`) before the server
// moves the room on to whatever's next — gives both players a fixed
// window to actually look at a match's result instead of being cut off
// the instant it lands. Frontend mirrors this by only tearing the result
// modal down once it actually receives that delayed message.
const RESULT_VIEW_MS = 10_000;
// code (number) -> room. A room's code is freed (removed from this map) // code (number) -> room. A room's code is freed (removed from this map)
// as soon as the room closes, so a future room:create can reuse it. // as soon as the room closes, so a future room:create can reuse it.
@ -45,6 +52,8 @@ function teardownRoom(room) {
for (const id of room.players) { for (const id of room.players) {
roomCodeByPlayerId.delete(id); roomCodeByPlayerId.delete(id);
} }
clearTimeout(room.nextMatchTimer);
clearTimeout(room.closeTimer);
} }
/** /**
@ -137,16 +146,26 @@ function handleSessionEnd(room, reason, quitterId) {
room.matchesPlayed += 1; room.matchesPlayed += 1;
room.previousThemeId = room.currentThemeId; room.previousThemeId = room.currentThemeId;
// Give both players RESULT_VIEW_MS to actually look at this match's
// result before the room moves on — see RESULT_VIEW_MS's own comment.
if (room.matchesPlayed >= MAX_MATCHES) { if (room.matchesPlayed >= MAX_MATCHES) {
room.closeTimer = setTimeout(() => {
room.closeTimer = null;
if (!rooms.has(room.code)) return; // torn down while waiting
closeRoom(room, 'matchLimit'); closeRoom(room, 'matchLimit');
}, RESULT_VIEW_MS);
return; return;
} }
const nextMatch = room.matchesPlayed + 1; const nextMatch = room.matchesPlayed + 1;
room.nextMatchTimer = setTimeout(() => {
room.nextMatchTimer = null;
if (!rooms.has(room.code)) return; // torn down while waiting
for (const id of room.players) { for (const id of room.players) {
sendTo(id, { type: 'round:nextMatch', match: nextMatch }); sendTo(id, { type: 'round:nextMatch', match: nextMatch });
} }
startMatch(room); startMatch(room);
}, RESULT_VIEW_MS);
} }
/** /**
@ -174,6 +193,8 @@ export function registerRoomHandlers() {
currentThemeId: null, currentThemeId: null,
votedThemeId: null, votedThemeId: null,
session: null, session: null,
nextMatchTimer: null,
closeTimer: null,
}; };
rooms.set(code, room); rooms.set(code, room);
roomCodeByPlayerId.set(ownerId, code); roomCodeByPlayerId.set(ownerId, code);

View file

@ -74,6 +74,7 @@
<button id="reset-btn" class="btn ghost">↺ RESET COLORS</button> <button id="reset-btn" class="btn ghost">↺ RESET COLORS</button>
<button id="reveal-btn" class="btn reveal" disabled>REVEAL MATCH ✨</button> <button id="reveal-btn" class="btn reveal" disabled>REVEAL MATCH ✨</button>
</div> </div>
<p id="reveal-vote-status" class="reveal-vote-status hidden"></p>
</aside> </aside>
</main> </main>
</div> </div>

View file

@ -77,6 +77,15 @@ export class ThemeGuessGame {
* listeners onto the same DOM). */ * listeners onto the same DOM). */
onCategoryAssigned?: (id: CategoryId, hex: string) => void; onCategoryAssigned?: (id: CategoryId, hex: string) => void;
/** Set by `MultiplayerMatch` while a networked round is live: routes
* the shared Reveal button's click through a server-mediated mutual
* vote instead of this class's own instant local `reveal()` — a
* multiplayer round's real result must wait for `round:reveal` (both
* players agreeing to reveal, or endsAt), never a single player's own
* click. `null` (solo/bot play, or no round active) falls back to the
* normal `reveal()`. Reset to `null` once the round tears down. */
revealOverride: (() => void) | null = null;
constructor( constructor(
themeId: ThemeId, themeId: ThemeId,
snippetIndex: number = pickRandomSnippetIndex(), snippetIndex: number = pickRandomSnippetIndex(),
@ -216,7 +225,13 @@ export class ThemeGuessGame {
this.closePicker(true); this.closePicker(true);
}); });
this.revealBtn.addEventListener('click', () => this.reveal()); this.revealBtn.addEventListener('click', () => {
if (this.revealOverride) {
this.revealOverride();
return;
}
this.reveal();
});
this.resetBtn.addEventListener('click', () => this.resetColors()); this.resetBtn.addEventListener('click', () => this.resetColors());
this.compareToggle.addEventListener('click', (e) => { this.compareToggle.addEventListener('click', (e) => {

View file

@ -106,6 +106,7 @@ export class MultiplayerMatch {
private readonly splitView = requireEl<HTMLElement>('split-view'); private readonly splitView = requireEl<HTMLElement>('split-view');
private readonly resultModal = requireEl<HTMLElement>('result-modal'); private readonly resultModal = requireEl<HTMLElement>('result-modal');
private readonly mpScoreboard = requireEl<HTMLElement>('mp-scoreboard'); private readonly mpScoreboard = requireEl<HTMLElement>('mp-scoreboard');
private readonly revealVoteStatusEl = requireEl<HTMLElement>('reveal-vote-status');
private readonly soloResultEls = [ private readonly soloResultEls = [
requireEl<HTMLElement>('solo-score-meter-wrap'), requireEl<HTMLElement>('solo-score-meter-wrap'),
requireEl<HTMLElement>('score-verdict'), requireEl<HTMLElement>('score-verdict'),
@ -135,6 +136,12 @@ export class MultiplayerMatch {
private unsubProgress: (() => void) | null = null; private unsubProgress: (() => void) | null = null;
private unsubWarning: (() => void) | null = null; private unsubWarning: (() => void) | null = null;
private unsubReveal: (() => void) | null = null; private unsubReveal: (() => void) | null = null;
private unsubRevealVoteUpdate: (() => void) | null = null;
/** Whether the local player has clicked Reveal Match this round and
* is waiting on the opponent to do the same — see `handleRevealVoteClick`.
* Reset per round by `startRound`/`teardownRound`. */
private revealVoted = false;
private quitArmed = false; private quitArmed = false;
private quitArmTimeoutId: number | null = null; private quitArmTimeoutId: number | null = null;
@ -185,11 +192,14 @@ export class MultiplayerMatch {
this.game.setTheme(payload.themeId); this.game.setTheme(payload.themeId);
this.game.setSnippet(payload.snippetIndex); this.game.setSnippet(payload.snippetIndex);
this.game.revealOverride = () => this.handleRevealVoteClick();
this.opponentView.setSnippet(payload.snippetIndex); this.opponentView.setSnippet(payload.snippetIndex);
this.opponentView.reset(); this.opponentView.reset();
this.resultModal.classList.add('hidden'); this.resultModal.classList.add('hidden');
this.mpScoreboard.classList.add('hidden'); this.mpScoreboard.classList.add('hidden');
this.revealVoted = false;
this.revealVoteStatusEl.classList.add('hidden');
this.unsubProgress = this.client.on('round:progress', (msg) => { this.unsubProgress = this.client.on('round:progress', (msg) => {
this.opponentView.markAssigned(msg.categoryId); this.opponentView.markAssigned(msg.categoryId);
@ -200,6 +210,11 @@ export class MultiplayerMatch {
this.unsubReveal = this.client.on('round:reveal', (msg) => { this.unsubReveal = this.client.on('round:reveal', (msg) => {
this.handleReveal(msg); this.handleReveal(msg);
}); });
this.unsubRevealVoteUpdate = this.client.on('round:revealVoteUpdate', () => {
if (this.revealVoted) return;
this.revealVoteStatusEl.textContent = 'Opponent voted to reveal — click Reveal Match to agree!';
this.revealVoteStatusEl.classList.remove('hidden');
});
this.stopTimer = startCountdown(this.timerEl, { this.stopTimer = startCountdown(this.timerEl, {
timeMode: payload.timeMode, timeMode: payload.timeMode,
@ -222,6 +237,21 @@ export class MultiplayerMatch {
this.client.send({ type: 'round:submit', colors: this.finalizeColors() }); this.client.send({ type: 'round:submit', colors: this.finalizeColors() });
} }
/** `ThemeGuessGame.revealOverride`'s target while a multiplayer round
* is live: the Reveal button (only enabled once every category is
* painted) casts this player's vote to end the round early instead of
* revealing anything locally — the real result only ever comes from
* `handleReveal`, once the server says both players agreed (or endsAt
* arrives regardless). A second click while already waiting is a
* no-op; the vote can't be un-cast. */
private handleRevealVoteClick(): void {
if (this.revealVoted) return;
this.revealVoted = true;
this.client.send({ type: 'round:revealVote', colors: this.finalizeColors() });
this.revealVoteStatusEl.textContent = 'Waiting for opponent to vote for reveal…';
this.revealVoteStatusEl.classList.remove('hidden');
}
private handleReveal(msg: RoundRevealMessage): void { private handleReveal(msg: RoundRevealMessage): void {
if (this.snippetIndex === null || this.themeId === null) return; if (this.snippetIndex === null || this.themeId === null) return;
@ -253,6 +283,12 @@ export class MultiplayerMatch {
this.unsubWarning = null; this.unsubWarning = null;
this.unsubReveal?.(); this.unsubReveal?.();
this.unsubReveal = null; this.unsubReveal = null;
this.unsubRevealVoteUpdate?.();
this.unsubRevealVoteUpdate = null;
if (this.game) this.game.revealOverride = null;
this.revealVoted = false;
this.revealVoteStatusEl.classList.add('hidden');
this.disarmQuit(); this.disarmQuit();
this.quitBtn.classList.add('hidden'); this.quitBtn.classList.add('hidden');

View file

@ -72,7 +72,7 @@ createSoloConfigFlow(
const privateRoomFlow = createPrivateRoomFlow( const privateRoomFlow = createPrivateRoomFlow(
client, client,
{ mount: privateRoomMount, themeNameBadge }, { mount: privateRoomMount, themeNameBadge, preview: previewElements },
{ hideMenu: () => showView(null), showMenu: () => showView('menu') }, { hideMenu: () => showView(null), showMenu: () => showView('menu') },
); );
@ -80,7 +80,7 @@ enterCodeBtn.addEventListener('click', () => privateRoomFlow.openJoin());
createRoomBtn.addEventListener('click', () => privateRoomFlow.openCreate()); createRoomBtn.addEventListener('click', () => privateRoomFlow.openCreate());
createMatchmakingFlow( createMatchmakingFlow(
{ findMatchBtn, statusMount: matchmakingStatusEl, themeNameBadge }, { findMatchBtn, statusMount: matchmakingStatusEl, themeNameBadge, preview: previewElements },
client, client,
{ showBoard: () => showView(null), showMenu: () => showView('menu') }, { showBoard: () => showView(null), showMenu: () => showView('menu') },
); );

View file

@ -62,6 +62,22 @@ export interface RoundRevealMessage {
colors: Record<string, Record<CategoryId, string>>; colors: Record<string, Record<CategoryId, string>>;
} }
/** Client -> server, ask to end the round early — see PROTOCOL.md
* "round:revealVote". Carries the same full-guess shape as
* `round:submit` (the Reveal button is only enabled once every category
* is painted); the round only actually ends once both players have
* sent this. */
export interface RoundRevealVoteMessage {
type: 'round:revealVote';
colors: Record<CategoryId, string>;
}
/** Server -> the other player, live notice that this player voted to
* reveal early — lets that client's UI nudge them to do the same. */
export interface RoundRevealVoteUpdateMessage {
type: 'round:revealVoteUpdate';
}
/** Server -> both players, private rooms only: next match in the series /** Server -> both players, private rooms only: next match in the series
* is about to start. */ * is about to start. */
export interface RoundNextMatchMessage { export interface RoundNextMatchMessage {
@ -189,6 +205,7 @@ export type ClientMessage =
| RoomJoinMessage | RoomJoinMessage
| RoundProgressMessage | RoundProgressMessage
| RoundSubmitMessage | RoundSubmitMessage
| RoundRevealVoteMessage
| VoteCastMessage | VoteCastMessage
| PlayerQuitMessage; | PlayerQuitMessage;
@ -201,6 +218,7 @@ export type ServerMessage =
| RoundProgressMessage | RoundProgressMessage
| RoundTimeWarningMessage | RoundTimeWarningMessage
| RoundRevealMessage | RoundRevealMessage
| RoundRevealVoteUpdateMessage
| RoundNextMatchMessage | RoundNextMatchMessage
| RoomCreatedMessage | RoomCreatedMessage
| RoomJoinedMessage | RoomJoinedMessage

View file

@ -283,6 +283,16 @@ body {
gap: 8px; gap: 8px;
} }
.panel-actions .btn { width: 100%; } .panel-actions .btn { width: 100%; }
/* `game/multiplayerMatch.ts`'s mutual reveal-vote status line, shown
* below the Reveal button once the local player has voted to reveal
* early and is waiting on the opponent to do the same (or vice versa). */
.reveal-vote-status {
margin: 8px 0 0;
font-size: 10px;
text-align: center;
color: var(--cyan);
}
.reveal-vote-status.hidden { display: none; }
/* ---------- split view (in-match opponent progress pane) ---------- */ /* ---------- split view (in-match opponent progress pane) ---------- */
/* `.board` now holds up to three flex children: the "Yours" split-pane /* `.board` now holds up to three flex children: the "Yours" split-pane
@ -503,6 +513,11 @@ body {
} }
.theme-card:hover { transform: translate(-1px, -1px); } .theme-card:hover { transform: translate(-1px, -1px); }
.theme-card.selected { border-color: var(--yellow); box-shadow: 3px 3px 0 var(--yellow); } .theme-card.selected { border-color: var(--yellow); box-shadow: 3px 3px 0 var(--yellow); }
/* `ui/themeVote.ts`: the opponent's live pick during theme voting — an
* outline (not a border/box-shadow, which `.selected` already owns) so
* both can be visible at once if a player picks the same theme their
* opponent already leans towards. */
.theme-card.opponent-pick { outline: 3px dashed var(--pink); outline-offset: 2px; }
.theme-swatches { display: flex; gap: 3px; } .theme-swatches { display: flex; gap: 3px; }
.theme-swatches i { width: 12px; height: 12px; border: 1px solid rgba(0,0,0,.5); display: block; } .theme-swatches i { width: 12px; height: 12px; border: 1px solid rgba(0,0,0,.5); display: block; }
.theme-name { .theme-name {
@ -728,6 +743,17 @@ body {
/* ---------- multiplayer scoreboard (#12: two-column round reveal) ---------- */ /* ---------- multiplayer scoreboard (#12: two-column round reveal) ---------- */
.mp-scoreboard.hidden { display: none; } .mp-scoreboard.hidden { display: none; }
/* `game/multiplayerMatch.ts` / `game/botMatch.ts` add `.hidden` to these
* four solo-result elements once a networked/bot round's real
* `renderMultiplayerResult` two-column scoreboard takes over — without
* these rules the class was inert (no selector consumed it) and the
* solo score panel stayed visible, stacked above the mp scoreboard. */
#solo-score-meter-wrap.hidden,
#score-verdict.hidden,
#score-breakdown.hidden,
#compare-toggle.hidden {
display: none;
}
.mp-winner-banner { .mp-winner-banner {
text-align: center; text-align: center;

View file

@ -23,6 +23,8 @@ import { THEMES } from '../data/themes';
import type { GameClient } from '../net/client'; import type { GameClient } from '../net/client';
import type { RoundStartMessage, TimeMode } from '../net/messages'; import type { RoundStartMessage, TimeMode } from '../net/messages';
import { renderBanBanner } from './banBanner'; import { renderBanBanner } from './banBanner';
import { runThemePreview, type PreviewFlowElements } from './previewFlow';
import { sound } from '../engine/sound';
function requireEl<T extends HTMLElement>(id: string): T { function requireEl<T extends HTMLElement>(id: string): T {
const el = document.getElementById(id); const el = document.getElementById(id);
@ -37,6 +39,11 @@ export interface MatchmakingFlowElements {
* whenever `findMatchBtn` is showing its idle state. */ * whenever `findMatchBtn` is showing its idle state. */
statusMount: HTMLElement; statusMount: HTMLElement;
themeNameBadge: HTMLElement; themeNameBadge: HTMLElement;
/** Shared `#preview-overlay` elements (see `ui/previewFlow.ts`) — a
* match's theme flashes here for `COUNTDOWN_SECONDS` before the board
* unlocks, same as solo/bot rounds, and matching the server's own
* `endsAt` offset (`backend/src/game/session.js`'s `PREVIEW_MS`). */
preview: PreviewFlowElements;
} }
export interface MatchmakingFlowHooks { export interface MatchmakingFlowHooks {
@ -71,7 +78,7 @@ export function createMatchmakingFlow(
client: GameClient, client: GameClient,
hooks: MatchmakingFlowHooks, hooks: MatchmakingFlowHooks,
): void { ): void {
const { findMatchBtn, statusMount, themeNameBadge } = elements; const { findMatchBtn, statusMount, themeNameBadge, preview } = elements;
const closeResultBtn = requireEl<HTMLButtonElement>('close-result-btn'); const closeResultBtn = requireEl<HTMLButtonElement>('close-result-btn');
const playAgainBtn = requireEl<HTMLButtonElement>('play-again-btn'); const playAgainBtn = requireEl<HTMLButtonElement>('play-again-btn');
@ -196,7 +203,10 @@ export function createMatchmakingFlow(
if (isNewMatch) match.bindGame(game); if (isNewMatch) match.bindGame(game);
hooks.showBoard(); hooks.showBoard();
match.startRound(payload); // Flash the assigned theme's real colors for the same window the
// server already baked into round:start's endsAt (session.js's
// PREVIEW_MS) before actually starting the round's countdown.
runThemePreview(preview, payload.themeId, () => match!.startRound(payload), () => sound.playOpen());
} }
findMatchBtn.addEventListener('click', () => { findMatchBtn.addEventListener('click', () => {

View file

@ -42,6 +42,8 @@ import { MultiplayerMatch } from '../game/multiplayerMatch';
import { OpponentView } from '../game/opponentView'; import { OpponentView } from '../game/opponentView';
import { getSharedGame } from '../game/sharedGame'; import { getSharedGame } from '../game/sharedGame';
import { openThemeVote, type ThemeVoteHandle } from './themeVote'; import { openThemeVote, type ThemeVoteHandle } from './themeVote';
import { runThemePreview, type PreviewFlowElements } from './previewFlow';
import { sound } from '../engine/sound';
export interface PrivateRoomFlowElements { export interface PrivateRoomFlowElements {
/** Empty `.modal-overlay` mount (`#private-room-mount` in index.html) /** Empty `.modal-overlay` mount (`#private-room-mount` in index.html)
@ -51,6 +53,11 @@ export interface PrivateRoomFlowElements {
/** Same theme-name badge the solo flow updates, kept in sync with the /** Same theme-name badge the solo flow updates, kept in sync with the
* server-assigned `themeId` once a round actually starts. */ * server-assigned `themeId` once a round actually starts. */
themeNameBadge: HTMLElement; themeNameBadge: HTMLElement;
/** Shared `#preview-overlay` elements (see `ui/previewFlow.ts`) — every
* match in the room's up-to-5-match series flashes its theme here
* before the board unlocks, matching the server's `endsAt` offset
* (`backend/src/game/session.js`'s `PREVIEW_MS`). */
preview: PreviewFlowElements;
} }
export interface PrivateRoomFlowCallbacks { export interface PrivateRoomFlowCallbacks {
@ -110,7 +117,8 @@ export function createPrivateRoomFlow(
elements: PrivateRoomFlowElements, elements: PrivateRoomFlowElements,
callbacks: PrivateRoomFlowCallbacks, callbacks: PrivateRoomFlowCallbacks,
): PrivateRoomFlow { ): PrivateRoomFlow {
const { mount, themeNameBadge } = elements; const { mount, themeNameBadge, preview } = elements;
const resultModal = requireEl<HTMLElement>('result-modal');
let roomActive = false; let roomActive = false;
let lastJoinCode = ''; let lastJoinCode = '';
@ -122,12 +130,14 @@ export function createPrivateRoomFlow(
let activeVoteHandle: ThemeVoteHandle | null = null; let activeVoteHandle: ThemeVoteHandle | null = null;
// Set while the local theme-vote settle animation (spin/"YOU AGREE!") // Set while the local theme-vote settle animation (spin/"YOU AGREE!")
// is still playing. `round:start` for the settled match can arrive // is still playing, or while the "Match N of 5" transition banner is
// from the server before that animation finishes (the backend fires // showing. `round:start` for the settled/next match can arrive from
// it right after `vote:settled`) — in that case the payload is // the server before either finishes playing out (the backend fires it
// buffered here and `enterRound` runs once the animation completes, // right after `vote:settled`, and right after `round:nextMatch`) — in
// so the vote's own reveal is never cut short. // that case the payload is buffered here and the round begins once
// whichever is pending completes, so neither is ever cut short.
let voteAnimationPending = false; let voteAnimationPending = false;
let transitionPending = false;
let bufferedRoundStart: RoundStartMessage | null = null; let bufferedRoundStart: RoundStartMessage | null = null;
function clearTransitionTimeout(): void { function clearTransitionTimeout(): void {
@ -292,7 +302,13 @@ export function createPrivateRoomFlow(
</div>`; </div>`;
transitionTimeoutId = window.setTimeout(() => { transitionTimeoutId = window.setTimeout(() => {
transitionTimeoutId = null; transitionTimeoutId = null;
transitionPending = false;
render({ kind: 'hidden' }); render({ kind: 'hidden' });
if (bufferedRoundStart) {
const payload = bufferedRoundStart;
bufferedRoundStart = null;
beginRoundWithPreview(payload);
}
}, MATCH_TRANSITION_MS); }, MATCH_TRANSITION_MS);
break; break;
@ -346,6 +362,17 @@ export function createPrivateRoomFlow(
ensureMatch(payload).startRound(payload); ensureMatch(payload).startRound(payload);
} }
/** Flashes `payload.themeId`'s real colors for the same window the
* server already baked into `round:start.endsAt` (session.js's
* `PREVIEW_MS`) before actually mounting the round via `enterRound` —
* every match in the series gets this, not just match 1, since a
* `themeMode: 'random'` room picks a fresh theme each match. */
function beginRoundWithPreview(payload: RoundStartMessage): void {
render({ kind: 'hidden' });
callbacks.hideMenu();
runThemePreview(preview, payload.themeId, () => enterRound(payload), () => sound.playOpen());
}
client.on('room:created', (msg) => { client.on('room:created', (msg) => {
render({ kind: 'create-waiting', code: msg.code }); render({ kind: 'create-waiting', code: msg.code });
}); });
@ -373,26 +400,32 @@ export function createPrivateRoomFlow(
if (bufferedRoundStart) { if (bufferedRoundStart) {
const payload = bufferedRoundStart; const payload = bufferedRoundStart;
bufferedRoundStart = null; bufferedRoundStart = null;
enterRound(payload); beginRoundWithPreview(payload);
} }
}); });
}); });
client.on('round:nextMatch', (msg: RoundNextMatchMessage) => { client.on('round:nextMatch', (msg: RoundNextMatchMessage) => {
if (!roomActive) return; if (!roomActive) return;
// The RESULT_VIEW_MS delay the server put between round:reveal and
// this message (backend/src/rooms/room.js) is exactly the window
// the result modal (already showing via MultiplayerMatch.handleReveal)
// was up for review — hide it now, right as the series moves on.
resultModal.classList.add('hidden');
transitionPending = true;
render({ kind: 'match-transition', match: msg.match }); render({ kind: 'match-transition', match: msg.match });
}); });
client.on('round:start', (payload) => { client.on('round:start', (payload) => {
if (!roomActive) return; if (!roomActive) return;
if (voteAnimationPending) { if (voteAnimationPending || transitionPending) {
// The settled theme's `round:start` beat the local vote-modal // The settled theme's/next match's `round:start` beat the local
// reveal animation (see `voteAnimationPending`'s declaration) — // vote-modal or "Match N of 5" animation still playing — hold it
// hold it until the animation's settle callback fires. // until that finishes (see their respective declarations/handlers).
bufferedRoundStart = payload; bufferedRoundStart = payload;
return; return;
} }
enterRound(payload); beginRoundWithPreview(payload);
}); });
client.on('room:closed', (msg) => { client.on('room:closed', (msg) => {
@ -400,6 +433,7 @@ export function createPrivateRoomFlow(
activeVoteHandle?.destroy(); activeVoteHandle?.destroy();
activeVoteHandle = null; activeVoteHandle = null;
voteAnimationPending = false; voteAnimationPending = false;
transitionPending = false;
bufferedRoundStart = null; bufferedRoundStart = null;
// The opponent's own `MultiplayerMatch.teardownRound()` never ran // The opponent's own `MultiplayerMatch.teardownRound()` never ran
// for this abandoned round (only the quitter's local instance tore // for this abandoned round (only the quitter's local instance tore

View file

@ -6,6 +6,11 @@ const SWATCH_KEYS = ['keyword', 'string', 'function', 'type'] as const;
export interface ThemeGrid { export interface ThemeGrid {
readonly selected: ThemeId; readonly selected: ThemeId;
setSelected(id: ThemeId): void; setSelected(id: ThemeId): void;
/** Highlights `id` as the opponent's current live pick (theme voting
* only — see `ui/themeVote.ts`) with its own distinct border color, so
* it stays visually separate from `selected` (this player's own pick)
* even when both point at the same card. `null` clears it. */
setOpponentPick(id: ThemeId | null): void;
} }
/** Renders the selectable theme cards into `container` and keeps them in /** Renders the selectable theme cards into `container` and keeps them in
@ -17,14 +22,18 @@ export function createThemeGrid(
onSelect: (id: ThemeId) => void, onSelect: (id: ThemeId) => void,
): ThemeGrid { ): ThemeGrid {
let selected = initialId; let selected = initialId;
let opponentPick: ThemeId | null = null;
function render(): void { function render(): void {
container.innerHTML = ''; container.innerHTML = '';
for (const id in THEMES) { for (const id in THEMES) {
const theme = THEMES[id]; const theme = THEMES[id];
const classes = ['theme-card'];
if (id === selected) classes.push('selected');
if (id === opponentPick) classes.push('opponent-pick');
const card = document.createElement('button'); const card = document.createElement('button');
card.type = 'button'; card.type = 'button';
card.className = `theme-card${id === selected ? ' selected' : ''}`; card.className = classes.join(' ');
card.dataset.id = id; card.dataset.id = id;
card.style.setProperty('--card-bg', theme.colors.background); card.style.setProperty('--card-bg', theme.colors.background);
card.innerHTML = ` card.innerHTML = `
@ -46,5 +55,6 @@ export function createThemeGrid(
return { return {
get selected() { return selected; }, get selected() { return selected; },
setSelected(id: ThemeId) { selected = id; render(); }, setSelected(id: ThemeId) { selected = id; render(); },
setOpponentPick(id: ThemeId | null) { opponentPick = id; render(); },
}; };
} }

View file

@ -63,7 +63,7 @@ export function openThemeVote(
<div class="modal theme-vote-modal"> <div class="modal theme-vote-modal">
<h2>🗳 Pick a Theme</h2> <h2>🗳 Pick a Theme</h2>
<p class="theme-vote-countdown" data-el="countdown">${countdownText(msg.endsAt - Date.now())}</p> <p class="theme-vote-countdown" data-el="countdown">${countdownText(msg.endsAt - Date.now())}</p>
<div class="theme-vote-grid" data-el="grid"></div> <div class="theme-vote-grid theme-grid" data-el="grid"></div>
<p class="theme-vote-opponent" data-el="opponent">Opponent is choosing…</p> <p class="theme-vote-opponent" data-el="opponent">Opponent is choosing…</p>
</div>`; </div>`;
@ -71,7 +71,7 @@ export function openThemeVote(
const gridEl = mount.querySelector<HTMLElement>('[data-el="grid"]')!; const gridEl = mount.querySelector<HTMLElement>('[data-el="grid"]')!;
const opponentEl = mount.querySelector<HTMLElement>('[data-el="opponent"]')!; const opponentEl = mount.querySelector<HTMLElement>('[data-el="opponent"]')!;
createThemeGrid(gridEl, msg.themeIds[0], (id) => { const grid = createThemeGrid(gridEl, msg.themeIds[0], (id) => {
myPick = id; myPick = id;
client.send({ type: 'vote:cast', themeId: id }); client.send({ type: 'vote:cast', themeId: id });
}); });
@ -85,7 +85,8 @@ export function openThemeVote(
tickCountdown(); tickCountdown();
const unsubOpponent = client.on('vote:opponentChoice', (opp) => { const unsubOpponent = client.on('vote:opponentChoice', (opp) => {
opponentEl.textContent = `Opponent picked ${themeLabel(opp.themeId)}`; opponentEl.textContent = `Opponent chose ${themeLabel(opp.themeId)}`;
grid.setOpponentPick(opp.themeId);
}); });
function cleanupListeners(): void { function cleanupListeners(): void {