diff --git a/frontend/src/game/timerHud.ts b/frontend/src/game/timerHud.ts
new file mode 100644
index 0000000..90855ac
--- /dev/null
+++ b/frontend/src/game/timerHud.ts
@@ -0,0 +1,118 @@
+// In-match timer HUD: a countdown mode badge + progress bar driven purely
+// by the server-authoritative `endsAt` from `round:start`, plus a one-shot
+// "20 seconds left" toast for `round:timeWarning`. DOM-only, no class —
+// mirrors the plain-function style of `resultView.ts`. The client never
+// runs independent timer logic that could drift from the server: every
+// tick recomputes `endsAt - Date.now()` rather than counting its own
+// elapsed time.
+
+/** Discriminated union so a caller can't pass `endsAt` without a real
+ * 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. */
+export type TimerHudOptions =
+ | { timeMode: 1 | 2; endsAt: number }
+ | { timeMode: 'none'; endsAt?: null };
+
+const TIME_MODE_LABEL: Record<1 | 2 | 'none', string> = {
+ 1: '1 MIN',
+ 2: '2 MIN',
+ none: 'NO LIMIT',
+};
+
+const WARNING_DISMISS_MS = 4000;
+
+function formatClock(ms: number): string {
+ const totalSeconds = Math.max(0, Math.round(ms / 1000));
+ const m = Math.floor(totalSeconds / 60);
+ const s = totalSeconds % 60;
+ return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
+}
+
+function countdownMarkup(): string {
+ return `
+ ('.match-timer-badge')!;
+ const clock = container.querySelector('.match-timer-clock')!;
+ const fill = container.querySelector('.match-timer-fill');
+ badge.textContent = TIME_MODE_LABEL[options.timeMode];
+
+ const startedAt = Date.now();
+ let rafId: number | null = null;
+
+ function stop(): void {
+ if (rafId !== null) {
+ cancelAnimationFrame(rafId);
+ rafId = null;
+ }
+ }
+
+ function tick(): void {
+ if (options.timeMode === 'none') {
+ clock.textContent = formatClock(Date.now() - startedAt);
+ rafId = requestAnimationFrame(tick);
+ return;
+ }
+
+ const remaining = options.endsAt - Date.now();
+ clock.textContent = formatClock(Math.max(0, remaining));
+ if (fill) {
+ const total = options.timeMode * 60_000;
+ const pct = Math.max(0, Math.min(100, (remaining / total) * 100));
+ fill.style.width = `${pct}%`;
+ }
+ if (remaining <= 0) {
+ stop();
+ return;
+ }
+ rafId = requestAnimationFrame(tick);
+ }
+
+ tick();
+ return stop;
+}
+
+const pendingDismiss = new WeakMap();
+
+/** Renders a small, auto-dismissing "20 seconds left" toast into
+ * `container`. Positioned by CSS (`.match-timer-warning-toast`) to stay
+ * clear of the canvas and category panel, and with `pointer-events: none`
+ * so it never intercepts clicks on tokens or the color picker underneath. */
+export function showTimeWarning(container: HTMLElement): void {
+ const pending = pendingDismiss.get(container);
+ if (pending !== undefined) window.clearTimeout(pending);
+
+ container.innerHTML = '
+
+
`;
+}
+
+function elapsedMarkup(): string {
+ return `
+
+ 00:00
+
+
+ 00:00
+
`;
+}
+
+/** Renders the mode badge + (for 1/2-minute modes) progress bar + mm:ss
+ * readout into `container`, and starts driving it from `options.endsAt`
+ * via requestAnimationFrame. Returns a `stop()` that cancels the animation
+ * loop; call it on round end/teardown to avoid leaking the RAF handle. */
+export function startCountdown(container: HTMLElement, options: TimerHudOptions): () => void {
+ const isCountdown = options.timeMode !== 'none';
+ container.innerHTML = isCountdown ? countdownMarkup() : elapsedMarkup();
+ container.classList.remove('hidden');
+
+ const badge = container.querySelector⏰ 20 seconds left!
';
+ container.classList.remove('hidden');
+
+ const timeoutId = window.setTimeout(() => {
+ container.classList.add('hidden');
+ container.innerHTML = '';
+ pendingDismiss.delete(container);
+ }, WARNING_DISMISS_MS);
+ pendingDismiss.set(container, timeoutId);
+}
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 0bbb9f4..2809e99 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -169,6 +169,61 @@ body {
}
#progress-label { font-family: var(--pixel); font-size: 9px; color: var(--fg-dim); white-space: nowrap; }
+/* ---------- 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. */
+.match-timer-hud {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-family: var(--pixel);
+ font-size: 9px;
+ color: var(--fg);
+ background: var(--panel-2);
+ border: 3px solid var(--ink);
+ padding: 8px 12px;
+ box-shadow: 3px 3px 0 var(--ink);
+}
+#match-timer-hud.hidden { display: none; }
+.match-timer-badge { color: var(--yellow); flex-shrink: 0; white-space: nowrap; }
+.match-timer-track {
+ flex: 1;
+ min-width: 80px;
+ height: 12px;
+ background: var(--panel-3);
+ border: 2px solid var(--ink);
+ overflow: hidden;
+}
+.match-timer-fill {
+ height: 100%;
+ width: 100%;
+ background: var(--cyan);
+ transition: width .3s linear, background-color .3s linear;
+}
+.match-timer-clock { flex-shrink: 0; white-space: nowrap; color: var(--fg-dim); }
+.match-timer-hud--elapsed .match-timer-clock { color: var(--fg); }
+
+.match-timer-warning-toast {
+ position: fixed;
+ top: 68px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 80;
+ pointer-events: none;
+ font-family: var(--pixel);
+ font-size: 10px;
+ color: var(--ink);
+ background: var(--yellow);
+ border: 3px solid var(--ink);
+ box-shadow: 3px 3px 0 var(--ink);
+ padding: 8px 14px;
+ animation: modalIn .2s steps(4);
+}
+#match-timer-warning.hidden { display: none; }
+
/* ---------- board layout ---------- */
.board {