diff --git a/frontend/index.html b/frontend/index.html
index c29d298..8713b86 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -59,6 +59,21 @@
+
+
diff --git a/frontend/src/game/opponentView.ts b/frontend/src/game/opponentView.ts
new file mode 100644
index 0000000..dfcb6c0
--- /dev/null
+++ b/frontend/src/game/opponentView.ts
@@ -0,0 +1,109 @@
+import { tokenize } from '../engine/tokenizer';
+import { SNIPPETS, pickRandomSnippetIndex } from '../data/snippets';
+import type { CategoryId, Token } from '../types';
+import { FONT_SIZE, FONT_STACK, GUTTER, LINE_HEIGHT, PAD } from './layoutConstants';
+import { drawOpponentFrame } from './renderer';
+
+function requireEl(id: string): T {
+ const el = document.getElementById(id);
+ if (!el) throw new Error(`Missing required element #${id}`);
+ return el as T;
+}
+
+/**
+ * Owns the "opponent progress" canvas: a read-only mirror of the shared
+ * match snippet that reveals only a binary "has the opponent assigned
+ * this category yet?" signal, driven by `round:progress` messages
+ * (see PROTOCOL.md — that message carries a bare `CategoryId`, never a
+ * color). `markAssigned` is the only way to light up a category, and it
+ * only ever accepts a `CategoryId`, so a real opponent-chosen hex has no
+ * path into this view by construction.
+ *
+ * Mirrors `ThemeGuessGame`'s canvas-ownership pattern, but far simpler:
+ * no picker, no clicks, no animation loop — every redraw is a flat,
+ * instant swap (see `drawOpponentFrame`).
+ */
+export class OpponentView {
+ private readonly canvas: HTMLCanvasElement;
+ private readonly ctx: CanvasRenderingContext2D;
+
+ private snippetIndex: number;
+ private tokens: Token[] = [];
+ private lineCount = 1;
+ private maxCols = 0;
+ private charWidth = 0;
+ private width = 0;
+ private height = 0;
+ private assigned = new Set();
+
+ constructor(canvasId: string, snippetIndex: number = pickRandomSnippetIndex()) {
+ this.canvas = requireEl(canvasId);
+ this.ctx = this.canvas.getContext('2d')!;
+ this.snippetIndex = snippetIndex;
+ this.build();
+ }
+
+ /** Forces a specific snippet (by stable `SNIPPETS` index) and clears
+ * progress — mirrors `ThemeGuessGame.setSnippet`, used to keep both
+ * players' panes on the same source file each match. */
+ setSnippet(index: number): void {
+ this.snippetIndex = index;
+ this.assigned = new Set();
+ this.build();
+ }
+
+ /** Marks one category as assigned by the opponent and redraws. Takes
+ * only a `CategoryId` — never a color — so there is no code path for
+ * an opponent's real chosen hex to reach this view. */
+ markAssigned(categoryId: CategoryId): void {
+ this.assigned.add(categoryId);
+ this.draw();
+ }
+
+ /** Clears every progress signal for reuse across matches, without
+ * touching the current snippet/layout. */
+ reset(): void {
+ this.assigned = new Set();
+ this.draw();
+ }
+
+ private build(): void {
+ this.tokens = tokenize(SNIPPETS[this.snippetIndex]);
+
+ this.lineCount = 1;
+ this.maxCols = 0;
+ let col = 0;
+ for (const t of this.tokens) {
+ if (t.type === 'newline') { this.lineCount++; col = 0; }
+ else { col += t.text.length; this.maxCols = Math.max(this.maxCols, col); }
+ }
+
+ this.setupCanvasSize();
+ this.draw();
+ }
+
+ private setupCanvasSize(): void {
+ const dpr = window.devicePixelRatio || 1;
+ this.ctx.font = `${FONT_SIZE}px ${FONT_STACK}`;
+ this.charWidth = this.ctx.measureText('M').width;
+ this.width = GUTTER + PAD * 2 + this.maxCols * this.charWidth;
+ this.height = PAD * 2 + this.lineCount * LINE_HEIGHT;
+ this.canvas.style.width = `${this.width}px`;
+ this.canvas.style.height = `${this.height}px`;
+ this.canvas.width = this.width * dpr;
+ this.canvas.height = this.height * dpr;
+ this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ }
+
+ private draw(): void {
+ drawOpponentFrame({
+ ctx: this.ctx,
+ width: this.width,
+ height: this.height,
+ charWidth: this.charWidth,
+ lineCount: this.lineCount,
+ tokens: this.tokens,
+ assigned: this.assigned,
+ }, performance.now());
+ }
+}
diff --git a/frontend/src/game/renderer.ts b/frontend/src/game/renderer.ts
index 41ea4ae..483baba 100644
--- a/frontend/src/game/renderer.ts
+++ b/frontend/src/game/renderer.ts
@@ -64,3 +64,66 @@ export function drawFrame(state: RenderState, now: number): void {
state.particles.draw(ctx);
}
+
+/** Flat color used to signal "opponent has assigned this category" in
+ * the opponent-progress pane. Never a real chosen hex — the opponent's
+ * actual color is never transmitted or known here (see PROTOCOL.md
+ * `round:progress`), so this is the only color this view can ever show
+ * for a picked category. */
+export const OPPONENT_ASSIGNED_HEX = '#5fd75f';
+/** Flat grey used for categories the opponent hasn't picked yet. */
+export const OPPONENT_UNASSIGNED_HEX = '#3a3a52';
+
+/** Everything one frame of the opponent-progress canvas needs. Deliberately
+ * narrower than `RenderState`: no per-category colors, no gradients/pulse
+ * animation, no particles — just which `CategoryId`s are "assigned" so far.
+ * Kept DOM-free like `drawFrame`. */
+export interface OpponentRenderState {
+ ctx: CanvasRenderingContext2D;
+ width: number;
+ height: number;
+ charWidth: number;
+ lineCount: number;
+ tokens: Token[];
+ assigned: ReadonlySet;
+}
+
+/** Draws the same snippet as `drawFrame`, but every category is either
+ * flat `OPPONENT_ASSIGNED_HEX` or flat `OPPONENT_UNASSIGNED_HEX` — an
+ * instant swap with no transition, since this pane only ever receives a
+ * binary per-category signal. */
+export function drawOpponentFrame(state: OpponentRenderState, _now: number): void {
+ const { ctx } = state;
+ ctx.clearRect(0, 0, state.width, state.height);
+
+ ctx.fillStyle = state.assigned.has('background') ? OPPONENT_ASSIGNED_HEX : OPPONENT_UNASSIGNED_HEX;
+ ctx.fillRect(0, 0, state.width, state.height);
+
+ ctx.fillStyle = 'rgba(0,0,0,.16)';
+ ctx.fillRect(0, 0, GUTTER, state.height);
+
+ ctx.fillStyle = '#665c54';
+ ctx.font = `${FONT_SIZE - 3}px ${FONT_STACK}`;
+ ctx.textBaseline = 'alphabetic';
+ for (let l = 0; l < state.lineCount; l++) {
+ ctx.fillText(String(l + 1).padStart(2, ' '), 8, PAD + l * LINE_HEIGHT + FONT_SIZE - 4);
+ }
+
+ // Stroked outline so foreground text stays legible even when a token's
+ // category and the background happen to share the same flat color.
+ ctx.lineWidth = 3;
+ ctx.strokeStyle = 'rgba(10,10,18,.65)';
+ ctx.lineJoin = 'round';
+ for (const t of state.tokens) {
+ if (t.type === 'whitespace' || t.type === 'newline') continue;
+ const cat = t.type as CategoryId;
+ const x = GUTTER + PAD + t.col * state.charWidth;
+ const y = PAD + t.line * LINE_HEIGHT + FONT_SIZE - 4;
+ ctx.font = t.type === 'comment'
+ ? `italic ${FONT_SIZE}px ${FONT_STACK}`
+ : `${FONT_SIZE}px ${FONT_STACK}`;
+ ctx.fillStyle = state.assigned.has(cat) ? OPPONENT_ASSIGNED_HEX : OPPONENT_UNASSIGNED_HEX;
+ ctx.strokeText(t.text, x, y);
+ ctx.fillText(t.text, x, y);
+ }
+}
diff --git a/frontend/src/style.css b/frontend/src/style.css
index c82e754..0bbb9f4 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -223,6 +223,39 @@ body {
}
.panel-actions .btn { width: 100%; }
+/* ---------- split view (in-match opponent progress pane) ---------- */
+
+.split-view {
+ flex: 1 1 auto;
+ min-height: 0;
+ display: flex;
+ gap: 14px;
+}
+.split-view.hidden { display: none; }
+
+.split-pane {
+ flex: 1 1 0;
+ min-width: 0;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.split-pane-label {
+ font-family: var(--pixel);
+ font-size: 10px;
+ letter-spacing: .5px;
+ color: var(--fg-dim);
+ flex: 0 0 auto;
+}
+
+.split-canvas-wrap {
+ flex: 1 1 auto;
+}
+
+.split-canvas { display: block; }
+
.cat-card {
background: var(--panel-2);
border: 3px solid var(--ink);