diff --git a/frontend/index.html b/frontend/index.html
index b83dda0..ef4ee5b 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -33,7 +33,10 @@
0 / 12 TOKEN KINDS THEMED
-
+
diff --git a/frontend/src/game/multiplayerMatch.ts b/frontend/src/game/multiplayerMatch.ts
index a9c2b07..157f3a6 100644
--- a/frontend/src/game/multiplayerMatch.ts
+++ b/frontend/src/game/multiplayerMatch.ts
@@ -2,7 +2,10 @@
// `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.
+// once the server reveals both players' submissions. Also owns the
+// in-match Quit button (`#match-quit-btn`): a confirm-armed click sends
+// `player:quit` and tears the local view down immediately, no server
+// reply required (see `quit()`'s doc for why).
//
// `ThemeGuessGame` has no network knowledge of its own: this module
// supplies its `onCategoryAssigned` constructor callback (see
@@ -12,6 +15,12 @@
// with `(id, hex) => match.handleLocalAssignment(id, hex)`, then call
// `match.bindGame(game)` — so the callback closure can reference a
// `MultiplayerMatch` instance that already exists.
+//
+// Nothing constructs a `MultiplayerMatch` yet (no #14/#15 host exists
+// in main.ts) — the third constructor arg, `onQuit`, is this class's
+// own hook for that future host to route back to the main menu once a
+// quit completes; it's exercised by this ticket's own quit-button
+// wiring so it's ready for #14/#15 to pass in.
import { tokenize } from '../engine/tokenizer';
import { rgbToHex } from '../engine/colorUtils';
@@ -37,6 +46,11 @@ function requireEl(id: string): T {
const FALLBACK_BG_HEX = rgbToHex(UNSET_BG_RGB);
const FALLBACK_FG_HEX = rgbToHex(UNSET_FG_RGB);
+/** How long the Quit button stays "armed" (showing "Confirm Quit?")
+ * after a first click before reverting, so a stray double-tap can't
+ * quit a match by accident. */
+const QUIT_CONFIRM_WINDOW_MS = 3000;
+
/** 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. */
@@ -95,6 +109,7 @@ export class MultiplayerMatch {
private readonly timerEl = requireEl('match-timer-hud');
private readonly timerWarningEl = requireEl('match-timer-warning');
+ private readonly quitBtn = requireEl('match-quit-btn');
private readonly resultModal = requireEl('result-modal');
private readonly mpScoreboard = requireEl('mp-scoreboard');
private readonly soloResultEls = [
@@ -127,7 +142,23 @@ export class MultiplayerMatch {
private unsubWarning: (() => void) | null = null;
private unsubReveal: (() => void) | null = null;
- constructor(private readonly client: GameClient, private readonly opponentView: OpponentView) {}
+ private quitArmed = false;
+ private quitArmTimeoutId: number | null = null;
+
+ /** `onQuit`, if given, fires once the local player's quit actually
+ * goes through (button confirmed, `player:quit` sent, local view torn
+ * down) — the host of this `MultiplayerMatch` (main.ts or whatever
+ * mounts it; nothing does yet, see this class's header) uses it to
+ * route back to the main menu. Never fires for the remote end of a
+ * match ending (opponent quit, reveal, etc.) — this class doesn't
+ * listen for `room:playerLeft`/`room:closed` today. */
+ constructor(
+ private readonly client: GameClient,
+ private readonly opponentView: OpponentView,
+ private readonly onQuit?: () => void,
+ ) {
+ this.quitBtn.addEventListener('click', () => this.handleQuitClick());
+ }
/** Binds the `ThemeGuessGame` this match drives via `setSnippet`/
* `setTheme`. Must be constructed with its `onCategoryAssigned`
@@ -181,6 +212,8 @@ export class MultiplayerMatch {
endsAt: payload.endsAt,
onExpire: () => this.submitFinalColors(),
});
+
+ this.quitBtn.classList.remove('hidden');
}
/** Pads the tracked local color map with placeholders for any
@@ -225,5 +258,52 @@ export class MultiplayerMatch {
this.unsubWarning = null;
this.unsubReveal?.();
this.unsubReveal = null;
+
+ this.disarmQuit();
+ this.quitBtn.classList.add('hidden');
+ }
+
+ /** First click on the Quit button arms it (shows "Confirm Quit?" and
+ * reverts on its own after `QUIT_CONFIRM_WINDOW_MS`); a second click
+ * while armed actually quits. Requires two intentional clicks so a
+ * misclick can't forfeit a match by accident. */
+ private handleQuitClick(): void {
+ if (this.quitArmed) {
+ this.quit();
+ return;
+ }
+ this.armQuit();
+ }
+
+ private armQuit(): void {
+ this.quitArmed = true;
+ this.quitBtn.textContent = 'Confirm Quit?';
+ this.quitBtn.classList.add('armed');
+ this.quitArmTimeoutId = window.setTimeout(() => this.disarmQuit(), QUIT_CONFIRM_WINDOW_MS);
+ }
+
+ private disarmQuit(): void {
+ this.quitArmed = false;
+ this.quitBtn.textContent = '✕ Quit';
+ this.quitBtn.classList.remove('armed');
+ if (this.quitArmTimeoutId !== null) {
+ window.clearTimeout(this.quitArmTimeoutId);
+ this.quitArmTimeoutId = null;
+ }
+ }
+
+ /** Confirmed quit: sends `player:quit` (see PROTOCOL.md "Quit" — the
+ * server infers which match/room from the sending socket, no payload
+ * needed), then immediately tears down the local match view exactly
+ * as `handleReveal` does, without waiting for any server reply — a
+ * quit has no round:reveal to wait for. Note there is no ban-info
+ * reply to react to here: see `ui/banBanner.ts`'s header comment for
+ * why a matchmaking ban is only ever learned later, via `queue:banned`
+ * on a subsequent `queue:join`. */
+ private quit(): void {
+ this.client.send({ type: 'player:quit' });
+ this.teardownRound();
+ this.opponentView.reset();
+ this.onQuit?.();
}
}
diff --git a/frontend/src/style.css b/frontend/src/style.css
index fba17dd..738a1d9 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -73,6 +73,8 @@ body {
.btn.reveal { background: linear-gradient(90deg, var(--yellow), var(--orange)); }
.btn.reveal:not(:disabled) { animation: revealPulse 1.6s steps(2) infinite; }
.btn.big { padding: 16px 22px; font-size: 13px; }
+.btn.danger { background: var(--red); color: var(--ink); }
+.btn.danger.armed { background: var(--yellow); }
@keyframes revealPulse {
0%, 100% { box-shadow: var(--shadow-off) var(--shadow-off) 0 var(--ink), 0 0 0 0 rgba(255,210,63,.6); }
50% { box-shadow: var(--shadow-off) var(--shadow-off) 0 var(--ink), 0 0 0 6px rgba(255,210,63,0); }
@@ -172,9 +174,11 @@ body {
/* ---------- match timer HUD (#11: countdown + time warning) ---------- */
/* A second, visually distinct progress bar from .progress-wrap above —
* that one tracks "N/12 token kinds themed"; this one tracks time
- * remaining in the round. Built by game/timerHud.ts into the empty
- * #match-timer-hud / #match-timer-warning mounts in index.html. Hidden
- * by default: not mounted into any live view until a later ticket. */
+ * remaining in the round, alongside the quit button. Built by
+ * game/timerHud.ts / game/multiplayerMatch.ts into the empty
+ * #match-timer-hud / #match-quit-btn / #match-timer-warning mounts in
+ * index.html. Hidden by default: not mounted into any live view until a
+ * later ticket. */
.match-timer-hud {
display: flex;
align-items: center;
@@ -188,6 +192,8 @@ body {
box-shadow: 3px 3px 0 var(--ink);
}
#match-timer-hud.hidden { display: none; }
+.match-hud-row { display: flex; align-items: center; gap: 10px; }
+#match-quit-btn.hidden { display: none; }
.match-timer-badge { color: var(--yellow); flex-shrink: 0; white-space: nowrap; }
.match-timer-track {
flex: 1;
@@ -520,6 +526,23 @@ body {
.menu-section-actions { display: flex; flex-direction: column; gap: 8px; margin-top: auto; }
.menu-section-actions .btn { width: 100%; }
+/* Standalone matchmaking-ban countdown banner (frontend/src/ui/banBanner.ts).
+ * Not mounted anywhere yet — ready for ticket #14 to mount into
+ * #menu-matchmaking once queue:banned is wired up. */
+.ban-banner {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-family: var(--pixel);
+ font-size: 10px;
+ color: var(--ink);
+ background: var(--orange);
+ border: 3px solid var(--ink);
+ box-shadow: 3px 3px 0 var(--ink);
+ padding: 8px 10px;
+}
+.ban-banner-icon { flex-shrink: 0; }
+
#menu-solo .theme-grid { margin-bottom: 0; }
.solo-config-group { display: flex; flex-direction: column; gap: 6px; }
diff --git a/frontend/src/ui/banBanner.ts b/frontend/src/ui/banBanner.ts
new file mode 100644
index 0000000..fa06c28
--- /dev/null
+++ b/frontend/src/ui/banBanner.ts
@@ -0,0 +1,74 @@
+// Standalone "matchmaking ban" countdown banner.
+//
+// Integration point (read this before wiring it up): the ONLY wire
+// message that carries ban info is `queue:banned` (see
+// backend/src/PROTOCOL.md "Matchmaking" + "Quit"), which the server
+// sends in reply to a `queue:join` attempt made while banned. Quitting a
+// matchmaking match does NOT itself push any ban notification to the
+// quitter — confirmed by reading backend/src/game/session.js's
+// `quitSession` (it only `sendTo`s the *remaining* opponent,
+// `room:playerLeft` then `room:closed`) and
+// backend/src/matchmaking/queue.js's `tryMatch` `onEnd` callback (it
+// only records `bansByPlayerId.set(...)` server-side, with no `sendTo`
+// back to the quitter at all). The quitter only learns their
+// `bannedUntil` the next time *any* socket for that `playerId` sends
+// `queue:join` before the ban expires.
+//
+// So this module is not wired into `game/multiplayerMatch.ts`'s quit
+// flow — there is nothing for it to react to there. Ticket #14 (the real
+// "Find Match" flow) is the actual call site: it should call
+// `client.on('queue:banned', (msg) => renderBanBanner(el, msg.bannedUntil))`
+// and mount `el` inside `#menu-matchmaking` (see index.html), most likely
+// replacing/disabling `#find-match-btn` for the ban's duration.
+
+function formatRemaining(ms: number): string {
+ const totalSeconds = Math.max(0, Math.ceil(ms / 1000));
+ const m = Math.floor(totalSeconds / 60);
+ const s = totalSeconds % 60;
+ return `${m}:${String(s).padStart(2, '0')}`;
+}
+
+function bannerMarkup(): string {
+ return `
+
+ ⏳
+
+
`;
+}
+
+/** Renders a persistent "Matchmaking available in M:SS" banner into
+ * `container`, live-updating once a second from `bannedUntil` (the
+ * epoch-ms value delivered on a `queue:banned` message). Automatically
+ * clears itself once the ban expires. Returns a `stop()` — cancels the
+ * interval and clears `container` — call it on unmount, or before a
+ * fresh `renderBanBanner` call if a later `queue:banned` arrives with an
+ * updated `bannedUntil`. */
+export function renderBanBanner(container: HTMLElement, bannedUntil: number): () => void {
+ container.innerHTML = bannerMarkup();
+ container.classList.remove('hidden');
+ const text = container.querySelector('.ban-banner-text')!;
+
+ let intervalId: number | null = null;
+
+ function stop(): void {
+ if (intervalId !== null) {
+ window.clearInterval(intervalId);
+ intervalId = null;
+ }
+ container.innerHTML = '';
+ container.classList.add('hidden');
+ }
+
+ function tick(): void {
+ const remaining = bannedUntil - Date.now();
+ if (remaining <= 0) {
+ stop();
+ return;
+ }
+ text.textContent = `Matchmaking available in ${formatRemaining(remaining)}`;
+ }
+
+ tick();
+ intervalId = window.setInterval(tick, 1000);
+ return stop;
+}