MATCH RESULT
-
+
@@ -166,6 +166,27 @@
+
diff --git a/frontend/src/game/ThemeGuessGame.ts b/frontend/src/game/ThemeGuessGame.ts
index cfa5cd4..9931341 100644
--- a/frontend/src/game/ThemeGuessGame.ts
+++ b/frontend/src/game/ThemeGuessGame.ts
@@ -59,7 +59,16 @@ export class ThemeGuessGame {
private height = 0;
private categories!: CategoryStateMap;
- constructor(themeId: ThemeId, snippetIndex: number = pickRandomSnippetIndex()) {
+ /** `onCategoryAssigned`, when provided, fires at the end of every
+ * `applyColor` with the category and hex just assigned — multiplayer
+ * uses it to relay a bare `round:progress` and to track the local
+ * player's running color map for `round:submit`, without this class
+ * needing to know anything about the network. */
+ constructor(
+ themeId: ThemeId,
+ snippetIndex: number = pickRandomSnippetIndex(),
+ private readonly onCategoryAssigned?: (id: CategoryId, hex: string) => void,
+ ) {
this.themeId = themeId;
this.snippetIndex = snippetIndex;
this.build();
@@ -311,6 +320,7 @@ export class ThemeGuessGame {
for (const c of this.tokenCentersFor(id, 10)) this.fx.spawnBurst(c.x, c.y, hex, 10);
sound.playApply();
this.updateProgress();
+ this.onCategoryAssigned?.(id, hex);
}
private tokenCentersFor(id: CategoryId, max: number): { x: number; y: number }[] {
diff --git a/frontend/src/game/multiplayerMatch.ts b/frontend/src/game/multiplayerMatch.ts
new file mode 100644
index 0000000..a9c2b07
--- /dev/null
+++ b/frontend/src/game/multiplayerMatch.ts
@@ -0,0 +1,229 @@
+// Orchestrates one live multiplayer round: wires an existing
+// `ThemeGuessGame` + `OpponentView` + `timerHud` to a `GameClient`'s
+// round:start/round:progress/round:timeWarning/round:reveal messages
+// (see backend/src/PROTOCOL.md), and renders the two-column scoreboard
+// once the server reveals both players' submissions.
+//
+// `ThemeGuessGame` has no network knowledge of its own: this module
+// supplies its `onCategoryAssigned` constructor callback (see
+// `ThemeGuessGame`'s constructor doc) and routes it into
+// `handleLocalAssignment`. Composition therefore happens in two steps —
+// construct `MultiplayerMatch` first, then construct `ThemeGuessGame`
+// with `(id, hex) => match.handleLocalAssignment(id, hex)`, then call
+// `match.bindGame(game)` — so the callback closure can reference a
+// `MultiplayerMatch` instance that already exists.
+
+import { tokenize } from '../engine/tokenizer';
+import { rgbToHex } from '../engine/colorUtils';
+import { CATEGORY_META, THEMES } from '../data/themes';
+import { SNIPPETS } from '../data/snippets';
+import { getOrCreatePlayerId } from '../net/identity';
+import type { GameClient } from '../net/client';
+import type { RoundRevealMessage, RoundStartMessage } from '../net/messages';
+import type { CategoryId, CategoryStateMap, ThemeId } from '../types';
+import { UNSET_BG_RGB, UNSET_FG_RGB } from './layoutConstants';
+import { showTimeWarning, startCountdown } from './timerHud';
+import { computeMatchResult } from './scoring';
+import { renderMultiplayerResult } from './resultView';
+import type { OpponentView } from './opponentView';
+import type { ThemeGuessGame } from './ThemeGuessGame';
+
+function requireEl(id: string): T {
+ const el = document.getElementById(id);
+ if (!el) throw new Error(`Missing required element #${id}`);
+ return el as T;
+}
+
+const FALLBACK_BG_HEX = rgbToHex(UNSET_BG_RGB);
+const FALLBACK_FG_HEX = rgbToHex(UNSET_FG_RGB);
+
+/** Every category defaulted to its "never painted" placeholder — used
+ * both to pad a local submission that's missing categories at time-up,
+ * and to stand in for an opponent who never submitted at all. */
+function defaultColorMap(): Record {
+ const result = {} as Record;
+ for (const def of CATEGORY_META) {
+ result[def.id] = def.id === 'background' ? FALLBACK_BG_HEX : FALLBACK_FG_HEX;
+ }
+ return result;
+}
+
+/** Rebuilds the same per-category `weight`/`actualHex` derivation
+ * `ThemeGuessGame`'s private `build()` does from a snippet + theme, then
+ * overlays a submitted color map as `assignedHex`. `computeMatchResult`
+ * only reads `assignedHex`/`actualHex`/`weight`/`id` off a
+ * `CategoryStateMap`, so this lets `round:reveal` be scored for either
+ * player without `ThemeGuessGame` needing to expose its internal state —
+ * the animation-only fields are filled with inert placeholders. */
+function buildCategoryStateMap(
+ snippetIndex: number,
+ themeId: ThemeId,
+ colors: Record,
+): CategoryStateMap {
+ const tokens = tokenize(SNIPPETS[snippetIndex]);
+ const counts: Partial> = {};
+ for (const t of tokens) {
+ if (t.type === 'whitespace' || t.type === 'newline' || t.type === 'identifier') continue;
+ const id = t.type as CategoryId;
+ counts[id] = (counts[id] ?? 0) + 1;
+ }
+ const maxCount = Math.max(1, ...Object.values(counts));
+ const theme = THEMES[themeId];
+
+ const categories = {} as CategoryStateMap;
+ for (const def of CATEGORY_META) {
+ const count = counts[def.id] ?? 0;
+ const unset = def.id === 'background' ? UNSET_BG_RGB : UNSET_FG_RGB;
+ categories[def.id] = {
+ ...def,
+ actualHex: theme.colors[def.id],
+ weight: def.id === 'background' ? maxCount : Math.max(count, 3),
+ count,
+ assignedHex: colors[def.id] ?? null,
+ currentRgb: { ...unset },
+ fromRgb: { ...unset },
+ toRgb: { ...unset },
+ transitionStart: 0,
+ pulsePhase: 0,
+ };
+ }
+ return categories;
+}
+
+export class MultiplayerMatch {
+ private readonly localPlayerId = getOrCreatePlayerId();
+
+ private readonly timerEl = requireEl('match-timer-hud');
+ private readonly timerWarningEl = requireEl('match-timer-warning');
+ private readonly resultModal = requireEl('result-modal');
+ private readonly mpScoreboard = requireEl('mp-scoreboard');
+ private readonly soloResultEls = [
+ requireEl('solo-score-meter-wrap'),
+ requireEl('score-verdict'),
+ requireEl('score-breakdown'),
+ requireEl('compare-toggle'),
+ ];
+ private readonly mpResultElements = {
+ banner: requireEl('mp-winner-banner'),
+ you: {
+ number: requireEl('mp-you-number'),
+ meter: requireEl('mp-you-meter'),
+ breakdown: requireEl('mp-you-breakdown'),
+ },
+ opponent: {
+ number: requireEl('mp-opponent-number'),
+ meter: requireEl('mp-opponent-meter'),
+ breakdown: requireEl('mp-opponent-breakdown'),
+ },
+ };
+
+ private game: ThemeGuessGame | null = null;
+ private themeId: ThemeId | null = null;
+ private snippetIndex: number | null = null;
+ private localColors: Partial> = {};
+
+ private stopTimer: (() => void) | null = null;
+ private unsubProgress: (() => void) | null = null;
+ private unsubWarning: (() => void) | null = null;
+ private unsubReveal: (() => void) | null = null;
+
+ constructor(private readonly client: GameClient, private readonly opponentView: OpponentView) {}
+
+ /** Binds the `ThemeGuessGame` this match drives via `setSnippet`/
+ * `setTheme`. Must be constructed with its `onCategoryAssigned`
+ * pointed at this instance's `handleLocalAssignment` — see this
+ * module's header comment for the construction order. */
+ bindGame(game: ThemeGuessGame): void {
+ this.game = game;
+ }
+
+ /** `ThemeGuessGame`'s `onCategoryAssigned` callback target: tracks the
+ * local player's running color map (for the eventual `round:submit`)
+ * and relays a bare `round:progress` — never a color — to the
+ * opponent, per protocol. */
+ handleLocalAssignment(id: CategoryId, hex: string): void {
+ this.localColors[id] = hex;
+ this.client.send({ type: 'round:progress', categoryId: id });
+ }
+
+ /** Starts one round from a `round:start` payload: syncs the local
+ * board and opponent pane to the server-assigned snippet/theme, starts
+ * the countdown, and wires this round's progress/timeWarning/reveal
+ * listeners. Tears down any previous round's listeners/timer first. */
+ startRound(payload: RoundStartMessage): void {
+ if (!this.game) throw new Error('MultiplayerMatch: no ThemeGuessGame bound; call bindGame() first');
+
+ this.teardownRound();
+ this.localColors = {};
+ this.themeId = payload.themeId;
+ this.snippetIndex = payload.snippetIndex;
+
+ this.game.setTheme(payload.themeId);
+ this.game.setSnippet(payload.snippetIndex);
+ this.opponentView.setSnippet(payload.snippetIndex);
+ this.opponentView.reset();
+
+ this.resultModal.classList.add('hidden');
+ this.mpScoreboard.classList.add('hidden');
+
+ this.unsubProgress = this.client.on('round:progress', (msg) => {
+ this.opponentView.markAssigned(msg.categoryId);
+ });
+ this.unsubWarning = this.client.on('round:timeWarning', () => {
+ showTimeWarning(this.timerWarningEl);
+ });
+ this.unsubReveal = this.client.on('round:reveal', (msg) => {
+ this.handleReveal(msg);
+ });
+
+ this.stopTimer = startCountdown(this.timerEl, {
+ timeMode: payload.timeMode,
+ endsAt: payload.endsAt,
+ onExpire: () => this.submitFinalColors(),
+ });
+ }
+
+ /** Pads the tracked local color map with placeholders for any
+ * category the player never assigned, so `round:submit` always
+ * carries a full `Record`. */
+ private finalizeColors(): Record {
+ return { ...defaultColorMap(), ...this.localColors };
+ }
+
+ private submitFinalColors(): void {
+ this.client.send({ type: 'round:submit', colors: this.finalizeColors() });
+ }
+
+ private handleReveal(msg: RoundRevealMessage): void {
+ if (this.snippetIndex === null || this.themeId === null) return;
+
+ const opponentId = Object.keys(msg.colors).find((id) => id !== this.localPlayerId) ?? null;
+ const youColors = msg.colors[this.localPlayerId] ?? this.finalizeColors();
+ const opponentColors = (opponentId && msg.colors[opponentId]) || defaultColorMap();
+
+ const youResult = computeMatchResult(buildCategoryStateMap(this.snippetIndex, this.themeId, youColors));
+ const opponentResult = computeMatchResult(
+ buildCategoryStateMap(this.snippetIndex, this.themeId, opponentColors),
+ );
+
+ this.teardownRound();
+
+ this.resultModal.classList.remove('hidden');
+ for (const el of this.soloResultEls) el.classList.add('hidden');
+ this.mpScoreboard.classList.remove('hidden');
+ renderMultiplayerResult(this.mpResultElements, youResult, opponentResult);
+ }
+
+ /** Stops the countdown and unsubscribes this round's listeners.
+ * Idempotent — safe to call before a round has started. */
+ private teardownRound(): void {
+ this.stopTimer?.();
+ this.stopTimer = null;
+ this.unsubProgress?.();
+ this.unsubProgress = null;
+ this.unsubWarning?.();
+ this.unsubWarning = null;
+ this.unsubReveal?.();
+ this.unsubReveal = null;
+ }
+}
diff --git a/frontend/src/game/resultView.ts b/frontend/src/game/resultView.ts
index b4eda7f..6fdc824 100644
--- a/frontend/src/game/resultView.ts
+++ b/frontend/src/game/resultView.ts
@@ -1,6 +1,6 @@
import { easeOutCubic } from '../engine/colorUtils';
import { METER_BLOCKS } from './layoutConstants';
-import type { MatchResultRow } from '../types';
+import type { MatchResult, MatchResultRow } from '../types';
export function barColor(sim: number): string {
if (sim < 40) return '#ff5555';
@@ -71,3 +71,45 @@ export function renderScoreMeter(meterEl: HTMLElement, numberEl: HTMLElement, ov
};
requestAnimationFrame(animate);
}
+
+/** DOM refs for one side (you/opponent) of the two-column multiplayer
+ * scoreboard — mirrors the single-player score-meter + breakdown pair. */
+export interface MultiplayerResultColumnElements {
+ number: HTMLElement;
+ meter: HTMLElement;
+ breakdown: HTMLElement;
+}
+
+export interface MultiplayerResultElements {
+ banner: HTMLElement;
+ you: MultiplayerResultColumnElements;
+ opponent: MultiplayerResultColumnElements;
+}
+
+const WINNER_BANNER_CLASSES = ['mp-winner-you', 'mp-winner-opponent', 'mp-draw'];
+
+/** Renders both players' `MatchResult`s side by side into the two-column
+ * scoreboard, plus a winner banner: higher `overall` wins; an equal
+ * `overall` is an explicit draw, with no other tie-break. */
+export function renderMultiplayerResult(
+ elements: MultiplayerResultElements,
+ you: MatchResult,
+ opponent: MatchResult,
+): void {
+ renderScoreMeter(elements.you.meter, elements.you.number, you.overall);
+ renderBreakdown(elements.you.breakdown, you.rows);
+ renderScoreMeter(elements.opponent.meter, elements.opponent.number, opponent.overall);
+ renderBreakdown(elements.opponent.breakdown, opponent.rows);
+
+ elements.banner.classList.remove(...WINNER_BANNER_CLASSES);
+ if (you.overall === opponent.overall) {
+ elements.banner.textContent = "🤝 IT'S A DRAW!";
+ elements.banner.classList.add('mp-draw');
+ } else if (you.overall > opponent.overall) {
+ elements.banner.textContent = '🏆 YOU WIN!';
+ elements.banner.classList.add('mp-winner-you');
+ } else {
+ elements.banner.textContent = '💀 OPPONENT WINS';
+ elements.banner.classList.add('mp-winner-opponent');
+ }
+}
diff --git a/frontend/src/game/timerHud.ts b/frontend/src/game/timerHud.ts
index 90855ac..1b9dc9f 100644
--- a/frontend/src/game/timerHud.ts
+++ b/frontend/src/game/timerHud.ts
@@ -10,9 +10,12 @@
* deadline (or omit it with one). `timeMode` doubles as the mode-badge
* source and the progress bar's total duration (in minutes). `'none'` is
* solo's infinite-time mode: no deadline, so no progress bar — the clock
- * counts elapsed time up from when `startCountdown` was called instead. */
+ * counts elapsed time up from when `startCountdown` was called instead.
+ * `onExpire`, on the countdown variant only, fires exactly once when
+ * `endsAt` passes — multiplayer uses it to trigger the local player's
+ * `round:submit` without polling `startCountdown`'s return value. */
export type TimerHudOptions =
- | { timeMode: 1 | 2; endsAt: number }
+ | { timeMode: 1 | 2; endsAt: number; onExpire?: () => void }
| { timeMode: 'none'; endsAt?: null };
const TIME_MODE_LABEL: Record<1 | 2 | 'none', string> = {
@@ -87,6 +90,7 @@ export function startCountdown(container: HTMLElement, options: TimerHudOptions)
}
if (remaining <= 0) {
stop();
+ options.onExpire?.();
return;
}
rafId = requestAnimationFrame(tick);
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 2809e99..fba17dd 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -662,6 +662,52 @@ body {
}
.toggle-btn.active { background: var(--panel-3); color: var(--fg); }
+/* ---------- multiplayer scoreboard (#12: two-column round reveal) ---------- */
+
+.mp-scoreboard.hidden { display: none; }
+
+.mp-winner-banner {
+ text-align: center;
+ font-family: var(--pixel);
+ font-size: 13px;
+ padding: 10px;
+ margin-bottom: 14px;
+ border: 3px solid var(--ink);
+ background: var(--panel-2);
+ color: var(--fg);
+}
+.mp-winner-banner.mp-winner-you { background: var(--green); color: var(--ink); }
+.mp-winner-banner.mp-winner-opponent { background: var(--red); color: var(--ink); }
+.mp-winner-banner.mp-draw { background: var(--yellow); color: var(--ink); }
+
+.mp-columns {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 14px;
+}
+.mp-column {
+ border: 3px solid var(--ink);
+ background: var(--panel-2);
+ padding: 12px 10px;
+ min-width: 0;
+}
+.mp-column-label {
+ text-align: center;
+ font-family: var(--pixel);
+ font-size: 10px;
+ color: var(--fg-dim);
+ margin-bottom: 8px;
+ letter-spacing: .5px;
+}
+.mp-column .score-meter-number { font-size: 20px; }
+.mp-column .score-meter-track { gap: 2px; }
+.mp-column .meter-block { width: 8px; height: 14px; }
+.mp-column .breakdown { max-height: 200px; }
+
+@media (max-width: 640px) {
+ .mp-columns { grid-template-columns: 1fr; }
+}
+
.modal-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
.modal-actions .btn { flex: 1 1 auto; min-width: 110px; }