diff --git a/backend/src/PROTOCOL.md b/backend/src/PROTOCOL.md
index 9753874..e99683a 100644
--- a/backend/src/PROTOCOL.md
+++ b/backend/src/PROTOCOL.md
@@ -68,8 +68,9 @@ stateDiagram-v2
Painting --> Painting: round:progress (either direction, repeatable)
Painting --> Warning: round:timeWarning (t = endsAt - 20s)
Warning --> Warning: round:progress
- Warning --> Submitted: round:submit (per player, 0-2 times)
- Submitted --> Reveal: endsAt reached
+ Painting --> Submitted: round:submit / round:revealVote (per player, 0-2 times)
+ Warning --> Submitted: round:submit / round:revealVote (per player, 0-2 times)
+ Submitted --> Reveal: endsAt reached, or both round:revealVote
Reveal --> [*]: round:reveal
```
@@ -83,9 +84,16 @@ stateDiagram-v2
"endsAt": 1757500000000 // epoch ms; server-authoritative round-end deadline
}
```
- `endsAt` is computed server-side (`now + timeMode * 60_000`) and is the
- single source of truth for round timing; clients render a countdown
- from it but never decide when the round ends themselves.
+ `endsAt` is computed server-side (`now + PREVIEW_MS + timeMode * 60_000`,
+ `PREVIEW_MS` = 10s, `backend/src/game/session.js`) and is the single
+ 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
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
all-`null` map).
-- **`round:reveal`** — server -> both players, sent only once `endsAt`
- has passed (never early, even if both players already submitted) so
- neither client can infer the opponent's picks before the round is
- officially over.
+- **`round:revealVote`** — client -> server, the Reveal button's click
+ while a round is still live (only enabled once every category is
+ painted — see `frontend/src/game/ThemeGuessGame.ts`'s `revealBtn`
+ 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
{
"type": "round:reveal",
@@ -155,7 +183,12 @@ Private rooms additionally get:
- **`round:nextMatch`** — server -> both players, sent after a
`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
{ "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
before the 5-match series completed.
- - `matchLimit` — match 5 of 5's `round:reveal` was just delivered and
- the series cap was reached; the server closes the room on its own,
- no client action required.
+ - `matchLimit` — match 5 of 5's `round:reveal` was delivered and the
+ series cap was reached; sent `RESULT_VIEW_MS` after that
+ `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
one of them explicitly leaves the room via the post-game UI (a
normal, non-quit exit after `matchLimit` would otherwise leave the
diff --git a/backend/src/game/session.js b/backend/src/game/session.js
index 1ac4a3c..a0c019e 100644
--- a/backend/src/game/session.js
+++ b/backend/src/game/session.js
@@ -22,6 +22,13 @@ const WARNING_OFFSET_MS = 20_000;
// server reveals with whatever it has (empty color map for a client that
// never submitted).
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}$/;
@@ -120,7 +127,7 @@ export function createSession({ playerA, playerB, timeMode, themeId, snippetInde
? snippetIndex
: 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 = {
playerA,
@@ -131,6 +138,10 @@ export function createSession({ playerA, playerB, timeMode, themeId, snippetInde
endsAt,
onEnd,
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,
roundEnded: false,
warningTimer: null,
@@ -187,6 +198,33 @@ registerHandler('round:submit', (socket, message) => {
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) => {
const playerId = getPlayerId(socket);
const session = sessionsByPlayerId.get(playerId);
diff --git a/backend/src/rooms/room.js b/backend/src/rooms/room.js
index 135534e..c9418f1 100644
--- a/backend/src/rooms/room.js
+++ b/backend/src/rooms/room.js
@@ -20,6 +20,13 @@ import { startVote } from './themeVote.js';
const MAX_MATCHES = 5;
const CODE_MIN = 100000;
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)
// 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) {
roomCodeByPlayerId.delete(id);
}
+ clearTimeout(room.nextMatchTimer);
+ clearTimeout(room.closeTimer);
}
/**
@@ -137,16 +146,26 @@ function handleSessionEnd(room, reason, quitterId) {
room.matchesPlayed += 1;
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) {
- closeRoom(room, 'matchLimit');
+ room.closeTimer = setTimeout(() => {
+ room.closeTimer = null;
+ if (!rooms.has(room.code)) return; // torn down while waiting
+ closeRoom(room, 'matchLimit');
+ }, RESULT_VIEW_MS);
return;
}
const nextMatch = room.matchesPlayed + 1;
- for (const id of room.players) {
- sendTo(id, { type: 'round:nextMatch', match: nextMatch });
- }
- startMatch(room);
+ room.nextMatchTimer = setTimeout(() => {
+ room.nextMatchTimer = null;
+ if (!rooms.has(room.code)) return; // torn down while waiting
+ for (const id of room.players) {
+ sendTo(id, { type: 'round:nextMatch', match: nextMatch });
+ }
+ startMatch(room);
+ }, RESULT_VIEW_MS);
}
/**
@@ -174,6 +193,8 @@ export function registerRoomHandlers() {
currentThemeId: null,
votedThemeId: null,
session: null,
+ nextMatchTimer: null,
+ closeTimer: null,
};
rooms.set(code, room);
roomCodeByPlayerId.set(ownerId, code);
diff --git a/frontend/index.html b/frontend/index.html
index 785cbcd..3df0916 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -74,6 +74,7 @@
+
diff --git a/frontend/src/game/ThemeGuessGame.ts b/frontend/src/game/ThemeGuessGame.ts
index 2c0eaf6..ac883a4 100644
--- a/frontend/src/game/ThemeGuessGame.ts
+++ b/frontend/src/game/ThemeGuessGame.ts
@@ -77,6 +77,15 @@ export class ThemeGuessGame {
* listeners onto the same DOM). */
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(
themeId: ThemeId,
snippetIndex: number = pickRandomSnippetIndex(),
@@ -216,7 +225,13 @@ export class ThemeGuessGame {
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.compareToggle.addEventListener('click', (e) => {
diff --git a/frontend/src/game/multiplayerMatch.ts b/frontend/src/game/multiplayerMatch.ts
index c0b7b64..cd77453 100644
--- a/frontend/src/game/multiplayerMatch.ts
+++ b/frontend/src/game/multiplayerMatch.ts
@@ -106,6 +106,7 @@ export class MultiplayerMatch {
private readonly splitView = requireEl('split-view');
private readonly resultModal = requireEl('result-modal');
private readonly mpScoreboard = requireEl('mp-scoreboard');
+ private readonly revealVoteStatusEl = requireEl('reveal-vote-status');
private readonly soloResultEls = [
requireEl('solo-score-meter-wrap'),
requireEl('score-verdict'),
@@ -135,6 +136,12 @@ export class MultiplayerMatch {
private unsubProgress: (() => void) | null = null;
private unsubWarning: (() => 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 quitArmTimeoutId: number | null = null;
@@ -185,11 +192,14 @@ export class MultiplayerMatch {
this.game.setTheme(payload.themeId);
this.game.setSnippet(payload.snippetIndex);
+ this.game.revealOverride = () => this.handleRevealVoteClick();
this.opponentView.setSnippet(payload.snippetIndex);
this.opponentView.reset();
this.resultModal.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.opponentView.markAssigned(msg.categoryId);
@@ -200,6 +210,11 @@ export class MultiplayerMatch {
this.unsubReveal = this.client.on('round:reveal', (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, {
timeMode: payload.timeMode,
@@ -222,6 +237,21 @@ export class MultiplayerMatch {
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 {
if (this.snippetIndex === null || this.themeId === null) return;
@@ -253,6 +283,12 @@ export class MultiplayerMatch {
this.unsubWarning = null;
this.unsubReveal?.();
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.quitBtn.classList.add('hidden');
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index 5fd2e3c..2fe379f 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -72,7 +72,7 @@ createSoloConfigFlow(
const privateRoomFlow = createPrivateRoomFlow(
client,
- { mount: privateRoomMount, themeNameBadge },
+ { mount: privateRoomMount, themeNameBadge, preview: previewElements },
{ hideMenu: () => showView(null), showMenu: () => showView('menu') },
);
@@ -80,7 +80,7 @@ enterCodeBtn.addEventListener('click', () => privateRoomFlow.openJoin());
createRoomBtn.addEventListener('click', () => privateRoomFlow.openCreate());
createMatchmakingFlow(
- { findMatchBtn, statusMount: matchmakingStatusEl, themeNameBadge },
+ { findMatchBtn, statusMount: matchmakingStatusEl, themeNameBadge, preview: previewElements },
client,
{ showBoard: () => showView(null), showMenu: () => showView('menu') },
);
diff --git a/frontend/src/net/messages.ts b/frontend/src/net/messages.ts
index 6a1e39b..8159dac 100644
--- a/frontend/src/net/messages.ts
+++ b/frontend/src/net/messages.ts
@@ -62,6 +62,22 @@ export interface RoundRevealMessage {
colors: Record>;
}
+/** 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;
+}
+
+/** 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
* is about to start. */
export interface RoundNextMatchMessage {
@@ -189,6 +205,7 @@ export type ClientMessage =
| RoomJoinMessage
| RoundProgressMessage
| RoundSubmitMessage
+ | RoundRevealVoteMessage
| VoteCastMessage
| PlayerQuitMessage;
@@ -201,6 +218,7 @@ export type ServerMessage =
| RoundProgressMessage
| RoundTimeWarningMessage
| RoundRevealMessage
+ | RoundRevealVoteUpdateMessage
| RoundNextMatchMessage
| RoomCreatedMessage
| RoomJoinedMessage
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 17bf340..94fbeee 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -283,6 +283,16 @@ body {
gap: 8px;
}
.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) ---------- */
/* `.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.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 i { width: 12px; height: 12px; border: 1px solid rgba(0,0,0,.5); display: block; }
.theme-name {
@@ -728,6 +743,17 @@ body {
/* ---------- multiplayer scoreboard (#12: two-column round reveal) ---------- */
.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 {
text-align: center;
diff --git a/frontend/src/ui/matchmakingFlow.ts b/frontend/src/ui/matchmakingFlow.ts
index 9600c37..905f3de 100644
--- a/frontend/src/ui/matchmakingFlow.ts
+++ b/frontend/src/ui/matchmakingFlow.ts
@@ -23,6 +23,8 @@ import { THEMES } from '../data/themes';
import type { GameClient } from '../net/client';
import type { RoundStartMessage, TimeMode } from '../net/messages';
import { renderBanBanner } from './banBanner';
+import { runThemePreview, type PreviewFlowElements } from './previewFlow';
+import { sound } from '../engine/sound';
function requireEl(id: string): T {
const el = document.getElementById(id);
@@ -37,6 +39,11 @@ export interface MatchmakingFlowElements {
* whenever `findMatchBtn` is showing its idle state. */
statusMount: 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 {
@@ -71,7 +78,7 @@ export function createMatchmakingFlow(
client: GameClient,
hooks: MatchmakingFlowHooks,
): void {
- const { findMatchBtn, statusMount, themeNameBadge } = elements;
+ const { findMatchBtn, statusMount, themeNameBadge, preview } = elements;
const closeResultBtn = requireEl('close-result-btn');
const playAgainBtn = requireEl('play-again-btn');
@@ -196,7 +203,10 @@ export function createMatchmakingFlow(
if (isNewMatch) match.bindGame(game);
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', () => {
diff --git a/frontend/src/ui/privateRoomFlow.ts b/frontend/src/ui/privateRoomFlow.ts
index 55271e3..d98880c 100644
--- a/frontend/src/ui/privateRoomFlow.ts
+++ b/frontend/src/ui/privateRoomFlow.ts
@@ -42,6 +42,8 @@ import { MultiplayerMatch } from '../game/multiplayerMatch';
import { OpponentView } from '../game/opponentView';
import { getSharedGame } from '../game/sharedGame';
import { openThemeVote, type ThemeVoteHandle } from './themeVote';
+import { runThemePreview, type PreviewFlowElements } from './previewFlow';
+import { sound } from '../engine/sound';
export interface PrivateRoomFlowElements {
/** 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
* server-assigned `themeId` once a round actually starts. */
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 {
@@ -110,7 +117,8 @@ export function createPrivateRoomFlow(
elements: PrivateRoomFlowElements,
callbacks: PrivateRoomFlowCallbacks,
): PrivateRoomFlow {
- const { mount, themeNameBadge } = elements;
+ const { mount, themeNameBadge, preview } = elements;
+ const resultModal = requireEl('result-modal');
let roomActive = false;
let lastJoinCode = '';
@@ -122,12 +130,14 @@ export function createPrivateRoomFlow(
let activeVoteHandle: ThemeVoteHandle | null = null;
// Set while the local theme-vote settle animation (spin/"YOU AGREE!")
- // is still playing. `round:start` for the settled match can arrive
- // from the server before that animation finishes (the backend fires
- // it right after `vote:settled`) — in that case the payload is
- // buffered here and `enterRound` runs once the animation completes,
- // so the vote's own reveal is never cut short.
+ // is still playing, or while the "Match N of 5" transition banner is
+ // showing. `round:start` for the settled/next match can arrive from
+ // the server before either finishes playing out (the backend fires it
+ // right after `vote:settled`, and right after `round:nextMatch`) — in
+ // 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 transitionPending = false;
let bufferedRoundStart: RoundStartMessage | null = null;
function clearTransitionTimeout(): void {
@@ -292,7 +302,13 @@ export function createPrivateRoomFlow(
`;
transitionTimeoutId = window.setTimeout(() => {
transitionTimeoutId = null;
+ transitionPending = false;
render({ kind: 'hidden' });
+ if (bufferedRoundStart) {
+ const payload = bufferedRoundStart;
+ bufferedRoundStart = null;
+ beginRoundWithPreview(payload);
+ }
}, MATCH_TRANSITION_MS);
break;
@@ -346,6 +362,17 @@ export function createPrivateRoomFlow(
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) => {
render({ kind: 'create-waiting', code: msg.code });
});
@@ -373,26 +400,32 @@ export function createPrivateRoomFlow(
if (bufferedRoundStart) {
const payload = bufferedRoundStart;
bufferedRoundStart = null;
- enterRound(payload);
+ beginRoundWithPreview(payload);
}
});
});
client.on('round:nextMatch', (msg: RoundNextMatchMessage) => {
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 });
});
client.on('round:start', (payload) => {
if (!roomActive) return;
- if (voteAnimationPending) {
- // The settled theme's `round:start` beat the local vote-modal
- // reveal animation (see `voteAnimationPending`'s declaration) —
- // hold it until the animation's settle callback fires.
+ if (voteAnimationPending || transitionPending) {
+ // The settled theme's/next match's `round:start` beat the local
+ // vote-modal or "Match N of 5" animation still playing — hold it
+ // until that finishes (see their respective declarations/handlers).
bufferedRoundStart = payload;
return;
}
- enterRound(payload);
+ beginRoundWithPreview(payload);
});
client.on('room:closed', (msg) => {
@@ -400,6 +433,7 @@ export function createPrivateRoomFlow(
activeVoteHandle?.destroy();
activeVoteHandle = null;
voteAnimationPending = false;
+ transitionPending = false;
bufferedRoundStart = null;
// The opponent's own `MultiplayerMatch.teardownRound()` never ran
// for this abandoned round (only the quitter's local instance tore
diff --git a/frontend/src/ui/themeGrid.ts b/frontend/src/ui/themeGrid.ts
index cf2b2d8..fcad71c 100644
--- a/frontend/src/ui/themeGrid.ts
+++ b/frontend/src/ui/themeGrid.ts
@@ -6,6 +6,11 @@ const SWATCH_KEYS = ['keyword', 'string', 'function', 'type'] as const;
export interface ThemeGrid {
readonly selected: ThemeId;
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
@@ -17,14 +22,18 @@ export function createThemeGrid(
onSelect: (id: ThemeId) => void,
): ThemeGrid {
let selected = initialId;
+ let opponentPick: ThemeId | null = null;
function render(): void {
container.innerHTML = '';
for (const id in THEMES) {
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');
card.type = 'button';
- card.className = `theme-card${id === selected ? ' selected' : ''}`;
+ card.className = classes.join(' ');
card.dataset.id = id;
card.style.setProperty('--card-bg', theme.colors.background);
card.innerHTML = `
@@ -46,5 +55,6 @@ export function createThemeGrid(
return {
get selected() { return selected; },
setSelected(id: ThemeId) { selected = id; render(); },
+ setOpponentPick(id: ThemeId | null) { opponentPick = id; render(); },
};
}
diff --git a/frontend/src/ui/themeVote.ts b/frontend/src/ui/themeVote.ts
index 0146bc7..7e0618b 100644
--- a/frontend/src/ui/themeVote.ts
+++ b/frontend/src/ui/themeVote.ts
@@ -63,7 +63,7 @@ export function openThemeVote(