diff --git a/backend/src/PROTOCOL.md b/backend/src/PROTOCOL.md
index b25f84a..53e0384 100644
--- a/backend/src/PROTOCOL.md
+++ b/backend/src/PROTOCOL.md
@@ -54,8 +54,8 @@ socket if anything else arrives first.
## Round lifecycle (shared by matchmaking and rooms)
Both matchmaking matches and private-room matches run the same
-round state machine once two players are paired. `timeMode` (`1` or
-`2`, minutes) and the `themeId` for the round are decided upstream
+round state machine once two players are paired. `timeMode` (`2` or
+`4`, minutes) and the `themeId` for the round are decided upstream
(randomly for matchmaking, per room config for rooms) before
`round:start` fires.
@@ -77,7 +77,7 @@ stateDiagram-v2
"type": "round:start",
"snippetIndex": 2, // server-authoritative, see Snippet identity contract below
"themeId": "tokyoNight", // ThemeId; server-authoritative
- "timeMode": 2, // 1 | 2 (minutes); server-authoritative
+ "timeMode": 4, // 2 | 4 (minutes); server-authoritative
"endsAt": 1757500000000 // epoch ms; server-authoritative round-end deadline
}
```
@@ -172,7 +172,7 @@ stateDiagram-v2
```
- **`queue:join`** — client -> server. No mode parameter: the server
- randomly assigns 1-minute or 2-minute mode per match (each queued
+ randomly assigns 2-minute or 4-minute mode per match (each queued
player doesn't pick, so pairing isn't constrained by mode preference).
```jsonc
{ "type": "queue:join" }
@@ -194,7 +194,7 @@ stateDiagram-v2
of the queue. A `round:start` for match 1 follows immediately; there is
no matchmaking equivalent of a room code or lobby.
```jsonc
- { "type": "match:found", "opponentName": "ada", "timeMode": 1 }
+ { "type": "match:found", "opponentName": "ada", "timeMode": 2 }
```
Matchmaking matches are single-round (no best-of-5, no `round:nextMatch`)
@@ -218,8 +218,8 @@ stateDiagram-v2
- **`room:create`** — client -> server, creates a room and puts the
creator in it waiting for an opponent.
```jsonc
- { "type": "room:create", "timeMode": 2, "themeMode": "chosen" }
- // timeMode: 1 | 2 (minutes); themeMode: 'random' | 'chosen'
+ { "type": "room:create", "timeMode": 4, "themeMode": "chosen" }
+ // timeMode: 2 | 4 (minutes); themeMode: 'random' | 'chosen'
```
- **`room:created`** — server -> creator, with a short numeric code to
share out-of-band with the intended opponent.
@@ -238,7 +238,7 @@ stateDiagram-v2
"type": "room:joined",
"code": 483920,
"opponentName": "ada",
- "timeMode": 2,
+ "timeMode": 4,
"themeMode": "chosen"
}
```
diff --git a/backend/src/game/session.js b/backend/src/game/session.js
index 3ab7263..1ac4a3c 100644
--- a/backend/src/game/session.js
+++ b/backend/src/game/session.js
@@ -100,7 +100,7 @@ function quitSession(session, quitterId) {
/**
* Starts one round session between two already-identified players.
- * `timeMode` is 1 or 2 (minutes, per PROTOCOL.md); `themeId` and
+ * `timeMode` is 2 or 4 (minutes, per PROTOCOL.md); `themeId` and
* `snippetIndex` are decided upstream (or `snippetIndex` is picked
* randomly here if omitted). Sends round:start to both sockets
* immediately and returns the session object.
diff --git a/backend/src/matchmaking/README.md b/backend/src/matchmaking/README.md
index b7ad313..3b5e1cd 100644
--- a/backend/src/matchmaking/README.md
+++ b/backend/src/matchmaking/README.md
@@ -1,7 +1,7 @@
# matchmaking/
Pairs waiting players FIFO-style, randomly assigns per-match timeMode
-(1 or 2 minutes, never player-chosen) and theme, and enforces the
+(2 or 4 minutes, never player-chosen) and theme, and enforces the
2-minute matchmaking quit ban — see `queue.js` and
`../PROTOCOL.md`'s "Matchmaking" and "Quit" sections. Party/lobby
handling beyond a single 1v1 queue is not implemented.
diff --git a/backend/src/matchmaking/queue.js b/backend/src/matchmaking/queue.js
index 8f74a73..87e3d19 100644
--- a/backend/src/matchmaking/queue.js
+++ b/backend/src/matchmaking/queue.js
@@ -61,7 +61,7 @@ function tryMatch() {
const playerAId = popOldestWaiting();
const playerBId = popOldestWaiting();
- const timeMode = Math.random() < 0.5 ? 1 : 2;
+ const timeMode = Math.random() < 0.5 ? 2 : 4;
const themeId = pickRandomThemeId();
activeMatchmakingPlayerIds.add(playerAId);
diff --git a/backend/src/rooms/room.js b/backend/src/rooms/room.js
index 9776070..135534e 100644
--- a/backend/src/rooms/room.js
+++ b/backend/src/rooms/room.js
@@ -159,7 +159,7 @@ export function registerRoomHandlers() {
if (!ownerId) return;
const { timeMode, themeMode } = message;
- if (timeMode !== 1 && timeMode !== 2) return;
+ if (timeMode !== 2 && timeMode !== 4) return;
if (themeMode !== 'random' && themeMode !== 'chosen') return;
const code = generateCode();
diff --git a/frontend/index.html b/frontend/index.html
index 3e3355a..3d63df0 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -40,19 +40,30 @@
-
-
+
+
Opponent
+
+
+
🏁 Opponent finished!
+
+
-
-
-
Keyword
-
-
-
-
-
-
-
+
+
Yours
+
+
+
+
+
+
Keyword
+
+
+
+
+
+
+
+
@@ -65,21 +76,6 @@
-
-
-
-
Yours
-
-
-
-
-
-
Opponent
-
-
-
-
-
@@ -95,8 +91,8 @@
⏱ TIME
-
-
+
+
diff --git a/frontend/src/engine/bot.ts b/frontend/src/engine/bot.ts
index 2a7ea1b..9f9e948 100644
--- a/frontend/src/engine/bot.ts
+++ b/frontend/src/engine/bot.ts
@@ -7,7 +7,7 @@ import type { CategoryId, RGB } from '../types';
import { hexToRgb, rgbToHex, lerpRgb, similarityFromHex } from './colorUtils';
export type BotDifficulty = 'easy' | 'medium' | 'hard';
-export type BotTimeMode = '1min' | '2min' | 'none';
+export type BotTimeMode = '2min' | '4min' | 'none';
export interface BotCategoryInput {
id: CategoryId;
@@ -33,7 +33,7 @@ export interface BotPick {
// Pacing
// ---------------------------------------------------------------------------
-/** 1-minute mode collapses to the 'short' band, 2-minute and no-limit both
+/** 2-minute mode collapses to the 'short' band, 4-minute and no-limit both
* get the more generous 'long' band (same target pace either way — a
* countdown vs. no countdown doesn't change how "fast" the bot thinks). */
type TimeBand = 'short' | 'long';
@@ -54,13 +54,13 @@ interface TimeModeConfig {
const DEADLINE_SAFETY_MARGIN_MS = 1_500;
const TIME_MODE_CONFIG: Record = {
- // ~55s target, ±4s jitter -> finishes 51-59s, safely under the 60s cap.
- '1min': { targetMs: 55_000, jitterMs: 4_000, deadlineMs: 60_000 },
- // ~1m45s target, ±5s jitter -> finishes 100-110s, safely under the 120s cap.
- '2min': { targetMs: 105_000, jitterMs: 5_000, deadlineMs: 120_000 },
- // Same ~1m45s target as 2-minute mode (no deadline to clamp against, but
+ // ~1m50s target, ±8s jitter -> finishes 102-118s, safely under the 120s cap.
+ '2min': { targetMs: 110_000, jitterMs: 8_000, deadlineMs: 120_000 },
+ // ~3m30s target, ±10s jitter -> finishes 200-220s, safely under the 240s cap.
+ '4min': { targetMs: 210_000, jitterMs: 10_000, deadlineMs: 240_000 },
+ // Same ~3m30s target as 4-minute mode (no deadline to clamp against, but
// no reason for the bot to pace itself any differently).
- none: { targetMs: 105_000, jitterMs: 5_000, deadlineMs: null },
+ none: { targetMs: 210_000, jitterMs: 10_000, deadlineMs: null },
};
/** Splits `totalMs` into `n` ascending pick times that sum to totalMs, with
@@ -162,7 +162,7 @@ function pickHex(targetHex: string, difficulty: BotDifficulty, timeMode: BotTime
if (timeMode === 'none' && Math.random() < NO_LIMIT_PERFECT_CHANCE) {
return targetHex;
}
- const band: TimeBand = timeMode === '1min' ? 'short' : 'long';
+ const band: TimeBand = timeMode === '2min' ? 'short' : 'long';
const ceiling = ACCURACY_CEILING[difficulty][band];
const floor = Math.max(5, ceiling - ACCURACY_SPREAD[difficulty]);
const desiredSimilarity = floor + Math.random() * (ceiling - floor);
diff --git a/frontend/src/engine/categoryStats.ts b/frontend/src/engine/categoryStats.ts
new file mode 100644
index 0000000..39f56b1
--- /dev/null
+++ b/frontend/src/engine/categoryStats.ts
@@ -0,0 +1,60 @@
+// Derives per-category token counts and scoring weights from a
+// tokenized snippet — the shared "how much does each category matter"
+// logic `ThemeGuessGame.build()`, `botMatch.ts`, and `multiplayerMatch.ts`
+// each need to build a `CategoryStateMap` (directly, or via
+// `game/scoring.ts`'s weighted average). Pure and DOM-free, so it lives
+// here rather than being re-derived independently in each `game/` call
+// site (as it used to be — three copies of the same formula, one typo
+// away from silently diverging).
+
+import { CATEGORY_META } from '../data/themes';
+import type { CategoryId, Token } from '../types';
+
+export interface CategoryStats {
+ /** How many times each category's token kind appears in the snippet
+ * (always 0 for 'background', which isn't a token). Drives the panel
+ * card's "N tokens" label. */
+ counts: Record;
+ /** Scoring weight per category — see `computeCategoryStats`'s doc for
+ * the reasoning. Consumed by `game/scoring.ts`'s weighted average. */
+ weights: Record;
+}
+
+/** Foreground categories are weighted by their own token frequency —
+ * a color the player sees 30 times in the snippet should count more
+ * toward the final score than one they saw only once — floored at 3 so
+ * a rare kind (often just a single comment) still counts for something.
+ *
+ * `background` isn't a token and has no frequency of its own. Weighting
+ * it by the single *most* frequent category's count (as this used to)
+ * let one snippet-dependent spike — punctuation or operators easily top
+ * 70-100 occurrences — dwarf every other category combined, so the
+ * overall score ended up mostly measuring "did you get punctuation and
+ * the background close" and barely anything else. Weighting it as one
+ * *typical* category instead — the mean of the other eleven — keeps it
+ * meaningfully important (it's the single largest area of the screen)
+ * without letting it, or its formerly-conflated counterpart, swamp
+ * every other guess. */
+export function computeCategoryStats(tokens: Token[]): CategoryStats {
+ const counts = {} as Record;
+ for (const def of CATEGORY_META) counts[def.id] = 0;
+ for (const t of tokens) {
+ if (t.type === 'whitespace' || t.type === 'newline' || t.type === 'identifier') continue;
+ const id = t.type as CategoryId;
+ counts[id] += 1;
+ }
+
+ const weights = {} as Record;
+ let otherSum = 0;
+ let otherN = 0;
+ for (const def of CATEGORY_META) {
+ if (def.id === 'background') continue;
+ const w = Math.max(counts[def.id], 3);
+ weights[def.id] = w;
+ otherSum += w;
+ otherN += 1;
+ }
+ weights.background = Math.round(otherSum / otherN);
+
+ return { counts, weights };
+}
diff --git a/frontend/src/engine/colorUtils.ts b/frontend/src/engine/colorUtils.ts
index c9159f6..3ca3689 100644
--- a/frontend/src/engine/colorUtils.ts
+++ b/frontend/src/engine/colorUtils.ts
@@ -78,9 +78,13 @@ export function deltaE76(labA: Lab, labB: Lab): number {
}
// 100 at identical colors, decaying smoothly as perceptual distance grows.
+// Divisor tuned so a genuinely close guess (small hue/tone miss, dE~15-20)
+// lands around 75-85% while a clearly wrong one (opposite lightness or
+// hue, dE~80+) stays well under half — see `computeCategoryStats` for
+// the other half of "off/inconsistent" scores: per-category weighting.
export function similarityFromHex(hexA: string | null, hexB: string): number {
if (!hexA) return 0;
const dE = deltaE76(hexToLab(hexA), hexToLab(hexB));
- const sim = 100 * Math.exp(-dE / 45);
+ const sim = 100 * Math.exp(-dE / 50);
return Math.max(0, Math.min(100, sim));
}
diff --git a/frontend/src/game/ThemeGuessGame.ts b/frontend/src/game/ThemeGuessGame.ts
index 60e3e75..2c0eaf6 100644
--- a/frontend/src/game/ThemeGuessGame.ts
+++ b/frontend/src/game/ThemeGuessGame.ts
@@ -1,4 +1,5 @@
import { tokenize } from '../engine/tokenizer';
+import { computeCategoryStats } from '../engine/categoryStats';
import { CATEGORY_META, THEMES } from '../data/themes';
import { SNIPPETS, pickRandomSnippetIndex } from '../data/snippets';
import { easeOutCubic, hexToRgb, isValidHex, lerpRgb, rgbToHex } from '../engine/colorUtils';
@@ -50,6 +51,11 @@ export class ThemeGuessGame {
private compareMode: CompareMode = 'yours';
private lastTime = performance.now();
private pickerOrigin: RGB | null = null;
+ /** Set by `enableExtendedPlay()`; once true, `updateProgress()` stops
+ * gating `revealBtn` on full category completion. Reset back to
+ * false by `build()`/`setTheme()` so every fresh round starts
+ * normally gated again. */
+ private extendedPlay = false;
private tokens: Token[] = [];
private lineCount = 1;
@@ -87,6 +93,7 @@ export class ThemeGuessGame {
// ---------- setup ----------
private build(): void {
+ this.extendedPlay = false;
this.tokens = tokenize(SNIPPETS[this.snippetIndex]);
this.lineCount = 1;
@@ -97,23 +104,17 @@ export class ThemeGuessGame {
else { col += t.text.length; this.maxCols = Math.max(this.maxCols, col); }
}
- const counts: Partial> = {};
- for (const t of this.tokens) {
- if (t.type === 'whitespace' || t.type === 'newline' || t.type === 'identifier') continue;
- counts[t.type] = (counts[t.type] ?? 0) + 1;
- }
- const maxCount = Math.max(1, ...Object.values(counts));
+ const { counts, weights } = computeCategoryStats(this.tokens);
const theme = THEMES[this.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,
+ weight: weights[def.id],
+ count: counts[def.id],
assignedHex: null,
currentRgb: { ...unset },
fromRgb: { ...unset },
@@ -152,6 +153,7 @@ export class ThemeGuessGame {
// ---------- theme switching ----------
setTheme(themeId: ThemeId): void {
+ this.extendedPlay = false;
this.themeId = themeId;
const theme = THEMES[themeId];
for (const def of CATEGORY_META) {
@@ -350,7 +352,7 @@ export class ThemeGuessGame {
const done = CATEGORY_META.filter((d) => this.categories[d.id].assignedHex).length;
this.progressFill.style.width = `${(done / total) * 100}%`;
this.progressLabel.textContent = `${done} / ${total} TOKEN KINDS THEMED`;
- this.revealBtn.disabled = done < total;
+ this.revealBtn.disabled = !this.extendedPlay && done < total;
}
private resetColors(): void {
@@ -385,7 +387,14 @@ export class ThemeGuessGame {
// ---------- scoring / reveal ----------
- private reveal(): void {
+ /** Renders the score breakdown/verdict and shows the result modal.
+ * Normally wired to the Reveal button's click, but also called
+ * directly when a timed solo round's countdown reaches zero, so
+ * time running out actually resolves the round instead of leaving
+ * the player stuck on an un-revealed board. Safe to call more than
+ * once (e.g. a manual reveal followed by the timer expiring) — it
+ * just re-renders the same result. */
+ reveal(): void {
const { overall, rows } = computeMatchResult(this.categories);
renderBreakdown(this.breakdownEl, rows);
@@ -397,6 +406,24 @@ export class ThemeGuessGame {
if (overall >= 75) this.fx.spawnConfetti(160, this.width);
}
+ /** Permanently unlocks `revealBtn` for the rest of the current round
+ * (until the next `build()`/`setTheme()` resets it) — called once the
+ * player chooses to keep tweaking colors after an initial reveal
+ * ("Keep Comparing"), so re-revealing after a small tweak doesn't
+ * require re-filling every category from scratch. */
+ enableExtendedPlay(): void {
+ this.extendedPlay = true;
+ this.updateProgress();
+ }
+
+ /** True once every category has a player-assigned color — the ground
+ * truth `BotMatch` polls (rather than shadowing it with its own
+ * tracking, which `resetColors()` wouldn't invalidate) to gate the
+ * Reveal button on both the player and the bot having finished. */
+ isFullyAssigned(): boolean {
+ return CATEGORY_META.every((def) => this.categories[def.id].assignedHex !== null);
+ }
+
// ---------- render loop ----------
private loop(t: number): void {
diff --git a/frontend/src/game/botMatch.ts b/frontend/src/game/botMatch.ts
index 2bb9360..91f1bbd 100644
--- a/frontend/src/game/botMatch.ts
+++ b/frontend/src/game/botMatch.ts
@@ -4,17 +4,21 @@
// two-column scoreboard `MultiplayerMatch` uses once the round ends.
//
// Mirrors `multiplayerMatch.ts`'s shape and two-phase-init convention —
-// `ThemeGuessGame` has no knowledge of this module either: `soloConfigFlow`
-// supplies its `onCategoryAssigned` constructor callback and routes it into
-// `BotMatch.handleLocalAssignment`, exactly like `MultiplayerMatch` docs
-// describe. The one structural difference: there is no server, so this
-// module also decides *when* the round ends (timer deadline, or — in
-// no-limit mode, which has no deadline — once the bot's schedule has fully
-// played out and the player has manually revealed via the existing Reveal
-// button, matching how `ThemeGuessGame.reveal()` stays manually triggered
-// for solo play in every time mode).
+// `ThemeGuessGame` has no knowledge of this module's existence (it never
+// imports `BotMatch`): `soloConfigFlow` supplies its `onCategoryAssigned`
+// constructor callback and routes it into `BotMatch.handleLocalAssignment`,
+// exactly like `MultiplayerMatch` docs describe, and separately binds the
+// shared instance itself via `bindGame` so the Reveal button can be gated
+// on the ground-truth `isFullyAssigned()` (see `syncRevealAvailability`)
+// instead of shadowing that state locally. The one structural difference
+// from multiplayer: there is no server, so this module also decides
+// *when* the round ends — the timed modes' deadline, same as always, or
+// (in any mode) as soon as both the bot's schedule has fully played out
+// and the player has manually revealed via the Reveal button, which
+// `syncRevealAvailability` keeps disabled until exactly that point.
import { tokenize } from '../engine/tokenizer';
+import { computeCategoryStats } from '../engine/categoryStats';
import { rgbToHex } from '../engine/colorUtils';
import { computeBotSchedule } from '../engine/bot';
import type { BotDifficulty, BotPick, BotTimeMode } from '../engine/bot';
@@ -26,6 +30,7 @@ import { computeMatchResult } from './scoring';
import { renderMultiplayerResult } from './resultView';
import type { MultiplayerResultElements } from './resultView';
import { OpponentView } from './opponentView';
+import type { ThemeGuessGame } from './ThemeGuessGame';
function requireEl(id: string): T {
const el = document.getElementById(id);
@@ -58,25 +63,17 @@ function buildCategoryStateMap(
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 { counts, weights } = computeCategoryStats(tokenize(SNIPPETS[snippetIndex]));
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,
+ weight: weights[def.id],
+ count: counts[def.id],
assignedHex: colors[def.id] ?? null,
currentRgb: { ...unset },
fromRgb: { ...unset },
@@ -95,8 +92,8 @@ function buildCategoryStateMap(
* (see `multiplayerMatch.ts` vs `ThemeGuessGame.ts`'s category-map
* builders). No-limit mode has no deadline, so no entry here. */
const ROUND_DEADLINE_MS: Record, number> = {
- '1min': 60_000,
'2min': 120_000,
+ '4min': 240_000,
};
export interface BotMatchConfig {
@@ -131,6 +128,8 @@ export class BotMatch {
};
private readonly revealBtn = requireEl('reveal-btn');
+ private game: ThemeGuessGame | null = null;
+
private opponentView: OpponentView | null = null;
private schedule: BotPick[] = [];
private pickTimeoutIds: number[] = [];
@@ -139,7 +138,6 @@ export class BotMatch {
private themeId: ThemeId | null = null;
private snippetIndex: number | null = null;
- private timeMode: BotTimeMode = 'none';
private localColors: Partial> = {};
private ended = true;
@@ -150,14 +148,25 @@ export class BotMatch {
this.revealBtn.addEventListener('click', this.onRevealClick);
}
+ /** Binds the shared `ThemeGuessGame` this match's Reveal-button gating
+ * polls — see `syncRevealAvailability`. Must be called before `start`;
+ * `soloConfigFlow` does this right after every `getSharedGame` call,
+ * bot round or not, since it's a harmless no-op while `ended`. */
+ bindGame(game: ThemeGuessGame): void {
+ this.game = game;
+ }
+
/** `ThemeGuessGame`'s `onCategoryAssigned` callback target: tracks the
- * local player's running color map for the eventual scoreboard. A
- * no-op once the round has already ended (or before one has started),
- * so it's safe to wire unconditionally regardless of the currently
- * selected solo opponent. */
+ * local player's running color map for the eventual scoreboard, and
+ * re-syncs Reveal-button availability (the player finishing their own
+ * board is one of the two conditions it's gated on — see
+ * `syncRevealAvailability`). A no-op once the round has already ended
+ * (or before one has started), so it's safe to wire unconditionally
+ * regardless of the currently selected solo opponent. */
handleLocalAssignment(id: CategoryId, hex: string): void {
if (this.ended) return;
this.localColors[id] = hex;
+ this.syncRevealAvailability();
}
/** Starts one bot round: shows the split-view pane, (re)builds the
@@ -173,7 +182,6 @@ export class BotMatch {
this.localColors = {};
this.themeId = config.themeId;
this.snippetIndex = config.snippetIndex;
- this.timeMode = config.timeMode;
this.splitView.classList.remove('hidden');
if (!this.opponentView) {
@@ -185,6 +193,7 @@ export class BotMatch {
this.resultModal.classList.add('hidden');
this.mpScoreboard.classList.add('hidden');
+ this.syncRevealAvailability();
const theme = THEMES[config.themeId];
const categories = CATEGORY_META.map((def) => ({ id: def.id, hex: theme.colors[def.id] }));
@@ -201,7 +210,8 @@ export class BotMatch {
this.remainingPicks -= 1;
if (this.remainingPicks === 0) {
this.botFinished = true;
- this.maybeEndNoLimitRound();
+ this.syncRevealAvailability();
+ this.maybeEndRound();
}
}, pick.atMs);
this.pickTimeoutIds.push(timeoutId);
@@ -220,17 +230,31 @@ export class BotMatch {
this.splitView.classList.add('hidden');
}
+ /** Reveal is only ever enabled while a bot round is live and both
+ * sides have finished — the player's own board (per `ThemeGuessGame`'s
+ * ground-truth `isFullyAssigned`, not `localColors`, so a mid-round
+ * `resetColors()` can't leave this stuck enabled) and the bot's
+ * schedule. Runs after every local assignment and the bot's final
+ * pick — the two events that can flip either half of the condition. */
+ private syncRevealAvailability(): void {
+ if (this.ended) return;
+ const playerDone = this.game?.isFullyAssigned() ?? false;
+ this.revealBtn.disabled = !(playerDone && this.botFinished);
+ }
+
private readonly onRevealClick = (): void => {
this.playerRevealed = true;
- this.maybeEndNoLimitRound();
+ this.maybeEndRound();
};
- /** No-limit mode has no deadline, so it ends only once both the bot's
- * schedule has fully played out and the player has manually revealed
- * (the existing Reveal button, same manual-trigger convention solo
- * play already uses in every time mode). */
- private maybeEndNoLimitRound(): void {
- if (this.timeMode === 'none' && this.playerRevealed && this.botFinished) this.endRound();
+ /** Ends the round as soon as both sides are done and the player has
+ * manually revealed — the Reveal button is disabled until exactly
+ * that point (see `syncRevealAvailability`), so this fires the moment
+ * it's clicked, timed mode or not, instead of making the player wait
+ * out a deadline neither side needs anymore. A timed round whose
+ * deadline arrives first still ends itself via `endRound` regardless. */
+ private maybeEndRound(): void {
+ if (this.playerRevealed && this.botFinished) this.endRound();
}
private endRound(): void {
diff --git a/frontend/src/game/multiplayerMatch.ts b/frontend/src/game/multiplayerMatch.ts
index 8f79679..c0b7b64 100644
--- a/frontend/src/game/multiplayerMatch.ts
+++ b/frontend/src/game/multiplayerMatch.ts
@@ -23,6 +23,7 @@
// wiring so it's ready for #14/#15 to pass in.
import { tokenize } from '../engine/tokenizer';
+import { computeCategoryStats } from '../engine/categoryStats';
import { rgbToHex } from '../engine/colorUtils';
import { CATEGORY_META, THEMES } from '../data/themes';
import { SNIPPETS } from '../data/snippets';
@@ -74,25 +75,17 @@ function buildCategoryStateMap(
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 { counts, weights } = computeCategoryStats(tokenize(SNIPPETS[snippetIndex]));
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,
+ weight: weights[def.id],
+ count: counts[def.id],
assignedHex: colors[def.id] ?? null,
currentRgb: { ...unset },
fromRgb: { ...unset },
diff --git a/frontend/src/game/opponentView.ts b/frontend/src/game/opponentView.ts
index dfcb6c0..309936c 100644
--- a/frontend/src/game/opponentView.ts
+++ b/frontend/src/game/opponentView.ts
@@ -1,8 +1,10 @@
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';
+import { CATEGORY_META } from '../data/themes';
+import { easeOutCubic, hexToRgb, lerpRgb } from '../engine/colorUtils';
+import type { CategoryId, RGB, Token } from '../types';
+import { FONT_SIZE, FONT_STACK, GUTTER, LINE_HEIGHT, PAD, TRANSITION_MS } from './layoutConstants';
+import { drawOpponentFrame, OPPONENT_ASSIGNED_HEX, OPPONENT_UNASSIGNED_HEX } from './renderer';
function requireEl(id: string): T {
const el = document.getElementById(id);
@@ -10,6 +12,20 @@ function requireEl(id: string): T {
return el as T;
}
+const UNASSIGNED_RGB = hexToRgb(OPPONENT_UNASSIGNED_HEX);
+const ASSIGNED_RGB = hexToRgb(OPPONENT_ASSIGNED_HEX);
+
+/** One category's animated fill — a lerp from `from` to `to`, eased and
+ * timed exactly like `ThemeGuessGame`'s own `CategoryState.currentRgb`
+ * (see `layoutConstants.TRANSITION_MS`), except `to` is always either
+ * `UNASSIGNED_RGB` or `ASSIGNED_RGB`: never a real opponent-chosen hex. */
+interface FillState {
+ current: RGB;
+ from: RGB;
+ to: RGB;
+ start: number;
+}
+
/**
* Owns the "opponent progress" canvas: a read-only mirror of the shared
* match snippet that reveals only a binary "has the opponent assigned
@@ -19,13 +35,20 @@ function requireEl(id: string): T {
* 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`).
+ * Mirrors `ThemeGuessGame`'s canvas-ownership pattern closely: no picker,
+ * no clicks, but the same perpetual `requestAnimationFrame` loop easing
+ * each category's flat color in (see `FillState`), and the same
+ * "mutate state, let the loop pick it up next frame" convention — none
+ * of `setSnippet`/`markAssigned`/`reset` draw synchronously.
+ *
+ * Also owns the "opponent finished" banner: once every category has been
+ * marked assigned, `#opponent-complete-banner` is shown until the next
+ * `reset`/`setSnippet`.
*/
export class OpponentView {
private readonly canvas: HTMLCanvasElement;
private readonly ctx: CanvasRenderingContext2D;
+ private readonly completeBanner = requireEl('opponent-complete-banner');
private snippetIndex: number;
private tokens: Token[] = [];
@@ -35,12 +58,15 @@ export class OpponentView {
private width = 0;
private height = 0;
private assigned = new Set();
+ private fills = {} as Record;
constructor(canvasId: string, snippetIndex: number = pickRandomSnippetIndex()) {
this.canvas = requireEl(canvasId);
this.ctx = this.canvas.getContext('2d')!;
this.snippetIndex = snippetIndex;
+ this.resetFills();
this.build();
+ requestAnimationFrame((t) => this.loop(t));
}
/** Forces a specific snippet (by stable `SNIPPETS` index) and clears
@@ -49,22 +75,42 @@ export class OpponentView {
setSnippet(index: number): void {
this.snippetIndex = index;
this.assigned = new Set();
+ this.resetFills();
+ this.completeBanner.classList.add('hidden');
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. */
+ /** Marks one category as assigned by the opponent and starts its
+ * fade-to-green transition. Takes only a `CategoryId` — never a
+ * color — so there is no code path for an opponent's real chosen hex
+ * to reach this view. Shows the "opponent finished" banner once every
+ * category has been marked. Idempotent per category, so a duplicate
+ * `round:progress` can't restart an in-flight fade. */
markAssigned(categoryId: CategoryId): void {
+ if (this.assigned.has(categoryId)) return;
this.assigned.add(categoryId);
- this.draw();
+
+ const fill = this.fills[categoryId];
+ fill.from = fill.current;
+ fill.to = { ...ASSIGNED_RGB };
+ fill.start = performance.now();
+
+ if (this.assigned.size === CATEGORY_META.length) this.completeBanner.classList.remove('hidden');
}
- /** Clears every progress signal for reuse across matches, without
- * touching the current snippet/layout. */
+ /** Clears every progress signal (and the finished banner) for reuse
+ * across matches, without touching the current snippet/layout. */
reset(): void {
this.assigned = new Set();
- this.draw();
+ this.resetFills();
+ this.completeBanner.classList.add('hidden');
+ }
+
+ private resetFills(): void {
+ this.fills = {} as Record;
+ for (const def of CATEGORY_META) {
+ this.fills[def.id] = { current: { ...UNASSIGNED_RGB }, from: { ...UNASSIGNED_RGB }, to: { ...UNASSIGNED_RGB }, start: 0 };
+ }
}
private build(): void {
@@ -79,7 +125,6 @@ export class OpponentView {
}
this.setupCanvasSize();
- this.draw();
}
private setupCanvasSize(): void {
@@ -95,7 +140,26 @@ export class OpponentView {
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
- private draw(): void {
+ private loop(now: number): void {
+ this.tickFills(now);
+ this.draw(now);
+ requestAnimationFrame((t) => this.loop(t));
+ }
+
+ private tickFills(now: number): void {
+ for (const def of CATEGORY_META) {
+ const fill = this.fills[def.id];
+ if (!fill.start) continue;
+ const p = Math.min(1, (now - fill.start) / TRANSITION_MS);
+ fill.current = lerpRgb(fill.from, fill.to, easeOutCubic(p));
+ if (p >= 1) fill.start = 0;
+ }
+ }
+
+ private draw(now: number): void {
+ const currentRgb = {} as Record;
+ for (const def of CATEGORY_META) currentRgb[def.id] = this.fills[def.id].current;
+
drawOpponentFrame({
ctx: this.ctx,
width: this.width,
@@ -103,7 +167,7 @@ export class OpponentView {
charWidth: this.charWidth,
lineCount: this.lineCount,
tokens: this.tokens,
- assigned: this.assigned,
- }, performance.now());
+ currentRgb,
+ }, now);
}
}
diff --git a/frontend/src/game/renderer.ts b/frontend/src/game/renderer.ts
index 483baba..ceb7557 100644
--- a/frontend/src/game/renderer.ts
+++ b/frontend/src/game/renderer.ts
@@ -1,6 +1,6 @@
import { rgbToHex } from '../engine/colorUtils';
import type { ParticleSystem } from '../engine/particles';
-import type { CategoryId, CategoryStateMap, Token } from '../types';
+import type { CategoryId, CategoryStateMap, RGB, Token } from '../types';
import { FONT_SIZE, FONT_STACK, GUTTER, LINE_HEIGHT, PAD } from './layoutConstants';
/** Everything one frame of the code canvas needs to draw itself. Kept
@@ -75,9 +75,11 @@ export const OPPONENT_ASSIGNED_HEX = '#5fd75f';
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`. */
+ * narrower than `RenderState`: no gradient pulse or particles, and only
+ * ever a lerp between the two flat opponent-progress colors above — but
+ * (unlike a plain binary signal) `currentRgb` carries `OpponentView`'s
+ * in-flight per-category animation state, one entry per `CategoryId`
+ * including 'background'. Kept DOM-free like `drawFrame`. */
export interface OpponentRenderState {
ctx: CanvasRenderingContext2D;
width: number;
@@ -85,18 +87,20 @@ export interface OpponentRenderState {
charWidth: number;
lineCount: number;
tokens: Token[];
- assigned: ReadonlySet;
+ currentRgb: Record;
}
-/** 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. */
+/** Draws the same snippet as `drawFrame`, but every category is a flat
+ * `OPPONENT_UNASSIGNED_HEX`-to-`OPPONENT_ASSIGNED_HEX` fill driven by
+ * `state.currentRgb` — `OpponentView` animates that lerp the same way
+ * `ThemeGuessGame` animates the player's own canvas (see
+ * `layoutConstants.TRANSITION_MS`), just always between those two flat
+ * colors instead of an arbitrary chosen hex. */
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.fillStyle = rgbToHex(state.currentRgb.background);
ctx.fillRect(0, 0, state.width, state.height);
ctx.fillStyle = 'rgba(0,0,0,.16)';
@@ -122,7 +126,7 @@ export function drawOpponentFrame(state: OpponentRenderState, _now: number): voi
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.fillStyle = rgbToHex(state.currentRgb[cat]);
ctx.strokeText(t.text, x, y);
ctx.fillText(t.text, x, y);
}
diff --git a/frontend/src/game/scoring.ts b/frontend/src/game/scoring.ts
index 8503601..6104b70 100644
--- a/frontend/src/game/scoring.ts
+++ b/frontend/src/game/scoring.ts
@@ -2,20 +2,38 @@ import { similarityFromHex } from '../engine/colorUtils';
import { CATEGORY_META } from '../data/themes';
import type { CategoryStateMap, MatchResult } from '../types';
-/** Weighted-by-frequency average of how close each guessed color landed
- * to the theme's real color, in perceptual (CIELAB) distance. */
+// Power-mean exponent used to combine per-category similarities into one
+// overall score. 1 would be a plain weighted average — it only cares
+// about total similarity, so a guess that nails a few high-weight
+// categories and whiffs the rest scores the same as one that lands
+// respectably close on everything. Any exponent below 1 makes the mean
+// concave in each `sim`, so — by the power-mean inequality — two guesses
+// with the *same* weighted-average similarity are no longer scored
+// equally: the more evenly that similarity is spread across categories,
+// the higher the result, and spikier profiles (some near-perfect, others
+// badly missed) score strictly lower. 0.6 was picked empirically: harsh
+// enough to reward consistency, but nowhere near the geometric-mean limit
+// (exponent -> 0), which would crash the whole score to ~0 whenever a
+// single category was left unassigned (sim 0) — a real case, since a
+// round can be revealed before every category is filled.
+const EVENNESS_EXPONENT = 0.6;
+
+/** Weighted power-mean (see `EVENNESS_EXPONENT`) of how close each
+ * guessed color landed to the theme's real color, in perceptual
+ * (CIELAB) distance. */
export function computeMatchResult(categories: CategoryStateMap): MatchResult {
let totalWeight = 0;
- let weightedSum = 0;
+ let weightedPowerSum = 0;
const rows: MatchResult['rows'] = [];
for (const def of CATEGORY_META) {
const cat = categories[def.id];
const sim = similarityFromHex(cat.assignedHex, cat.actualHex);
totalWeight += cat.weight;
- weightedSum += sim * cat.weight;
+ weightedPowerSum += cat.weight * Math.pow(sim, EVENNESS_EXPONENT);
rows.push({ ...def, guess: cat.assignedHex, actualHex: cat.actualHex, sim });
}
- return { overall: Math.round(weightedSum / totalWeight), rows };
+ const overall = Math.pow(weightedPowerSum / totalWeight, 1 / EVENNESS_EXPONENT);
+ return { overall: Math.round(overall), rows };
}
diff --git a/frontend/src/game/timerHud.ts b/frontend/src/game/timerHud.ts
index 1b9dc9f..66c9847 100644
--- a/frontend/src/game/timerHud.ts
+++ b/frontend/src/game/timerHud.ts
@@ -15,12 +15,12 @@
* `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; onExpire?: () => void }
+ | { timeMode: 2 | 4; endsAt: number; onExpire?: () => void }
| { timeMode: 'none'; endsAt?: null };
-const TIME_MODE_LABEL: Record<1 | 2 | 'none', string> = {
- 1: '1 MIN',
+const TIME_MODE_LABEL: Record<2 | 4 | 'none', string> = {
2: '2 MIN',
+ 4: '4 MIN',
none: 'NO LIMIT',
};
@@ -50,7 +50,7 @@ function elapsedMarkup(): string {
`;
}
-/** Renders the mode badge + (for 1/2-minute modes) progress bar + mm:ss
+/** Renders the mode badge + (for 2/4-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. */
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index c6a7b88..bb71089 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -1,9 +1,6 @@
import './style.css';
-import { getSharedGame } from './game/sharedGame';
-import { THEMES } from './data/themes';
import { sound } from './engine/sound';
import { createThemeGrid } from './ui/themeGrid';
-import { runThemePreview } from './ui/previewFlow';
import { createSoloConfigFlow } from './ui/soloConfigFlow';
import { createMatchmakingFlow } from './ui/matchmakingFlow';
import { createPrivateRoomFlow } from './ui/privateRoomFlow';
@@ -58,24 +55,18 @@ function showView(name: ScreenName | null): void {
const themeGrid = createThemeGrid(themeGridEl, 'gruvbox', () => sound.playOpen());
-const soloConfigFlow = createSoloConfigFlow(
+createSoloConfigFlow(
{
section: menuSoloSection,
themePickerWrap: soloThemePickerWrap,
botDifficultyWrap: soloBotDifficultyWrap,
playBtn: startBtn,
timerMount: soloTimerMount,
+ themeNameBadge,
+ preview: previewElements,
},
themeGrid,
- (config) => {
- showView(null);
- runThemePreview(previewElements, config.themeId, () => {
- getSharedGame(config.themeId, config.snippetIndex, (id, hex) => soloConfigFlow.handleLocalAssignment(id, hex));
- themeNameBadge.textContent = THEMES[config.themeId].name;
- sound.playApply();
- soloConfigFlow.startTimer(config.timeMode);
- }, () => sound.playOpen());
- },
+ { hideMenu: () => showView(null), showMenu: () => showView('menu') },
);
const privateRoomFlow = createPrivateRoomFlow(
diff --git a/frontend/src/net/messages.ts b/frontend/src/net/messages.ts
index 247eef8..6a1e39b 100644
--- a/frontend/src/net/messages.ts
+++ b/frontend/src/net/messages.ts
@@ -7,7 +7,7 @@
import type { CategoryId, ThemeId } from '../types';
-export type TimeMode = 1 | 2;
+export type TimeMode = 2 | 4;
export type ThemeMode = 'random' | 'chosen';
export type RoomErrorReason = 'not_found' | 'full';
export type RoomClosedReason = 'quit' | 'matchLimit' | 'finished';
diff --git a/frontend/src/style.css b/frontend/src/style.css
index a7314ce..302de4f 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -285,14 +285,14 @@ body {
.panel-actions .btn { width: 100%; }
/* ---------- split view (in-match opponent progress pane) ---------- */
+/* `.board` now holds up to three flex children: the "Yours" split-pane
+ * (always present — it's the live interactive `#code-canvas`), the
+ * "Opponent" split-pane (`#split-view`, hidden outside a bot/multiplayer
+ * round), and the fixed-width `.panel-col`. Hiding `#split-view` lets
+ * `.yours-pane` fill the whole row, exactly reproducing solo-alone's
+ * original single-canvas layout. */
-.split-view {
- flex: 1 1 auto;
- min-height: 0;
- display: flex;
- gap: 14px;
-}
-.split-view.hidden { display: none; }
+.split-pane.hidden { display: none; }
.split-pane {
flex: 1 1 0;
@@ -311,12 +311,43 @@ body {
flex: 0 0 auto;
}
+/* Only meaningful once there's something to compare against — stays
+ * hidden while `#split-view` (the "Opponent" pane) is hidden, so solo
+ * "Alone" play still shows one unlabeled canvas, same as before this
+ * pane was mergeable with an opponent's. */
+.yours-label { display: none; }
+.board:has(> #split-view:not(.hidden)) .yours-label { display: block; }
+
.split-canvas-wrap {
flex: 1 1 auto;
}
.split-canvas { display: block; }
+/* Pixel-style toast over the opponent canvas once `OpponentView` has
+ * received every category — see `game/opponentView.ts`'s `markAssigned`. */
+.opponent-complete-banner {
+ position: absolute;
+ left: 50%;
+ top: 14px;
+ font-family: var(--pixel);
+ font-size: 11px;
+ color: var(--ink);
+ background: var(--green);
+ border: 3px solid var(--ink);
+ box-shadow: 3px 3px 0 var(--ink);
+ padding: 8px 12px;
+ white-space: nowrap;
+ z-index: 2;
+ animation: bannerPop .4s steps(4);
+}
+.opponent-complete-banner.hidden { display: none; }
+@keyframes bannerPop {
+ 0% { transform: translateX(-50%) scale(1); }
+ 40% { transform: translateX(-50%) scale(1.15); }
+ 100% { transform: translateX(-50%) scale(1); }
+}
+
.cat-card {
background: var(--panel-2);
border: 3px solid var(--ink);
diff --git a/frontend/src/ui/matchmakingFlow.ts b/frontend/src/ui/matchmakingFlow.ts
index 0f03163..9600c37 100644
--- a/frontend/src/ui/matchmakingFlow.ts
+++ b/frontend/src/ui/matchmakingFlow.ts
@@ -52,8 +52,8 @@ export interface MatchmakingFlowHooks {
type FlowState = 'idle' | 'searching' | 'banned' | 'found' | 'in-match';
const TIME_MODE_LABEL: Record = {
- 1: '🎯 1 MINUTE MATCH',
2: '🎯 2 MINUTE MATCH',
+ 4: '🎯 4 MINUTE MATCH',
};
/** Minimum time the assigned-mode banner stays up before handing off to
diff --git a/frontend/src/ui/privateRoomFlow.ts b/frontend/src/ui/privateRoomFlow.ts
index 8892655..55271e3 100644
--- a/frontend/src/ui/privateRoomFlow.ts
+++ b/frontend/src/ui/privateRoomFlow.ts
@@ -169,8 +169,8 @@ export function createPrivateRoomFlow(
⏱ TIME
-
-
+
+
@@ -190,7 +190,7 @@ export function createPrivateRoomFlow(
render({ kind: 'hidden' });
});
mount.querySelector('[data-action="submit"]')!.addEventListener('click', () => {
- const timeMode = Number(radioValue(mount, 'pr-time-mode', '1')) as TimeMode;
+ const timeMode = Number(radioValue(mount, 'pr-time-mode', '2')) as TimeMode;
const themeMode = radioValue(mount, 'pr-theme-mode', 'random') as ThemeMode;
client.send({ type: 'room:create', timeMode, themeMode });
});
diff --git a/frontend/src/ui/soloConfigFlow.ts b/frontend/src/ui/soloConfigFlow.ts
index be41aa4..8ba81d6 100644
--- a/frontend/src/ui/soloConfigFlow.ts
+++ b/frontend/src/ui/soloConfigFlow.ts
@@ -1,8 +1,13 @@
import { THEMES } from '../data/themes';
import { pickRandomSnippetIndex } from '../data/snippets';
import type { BotDifficulty, BotTimeMode } from '../engine/bot';
+import { sound } from '../engine/sound';
import { BotMatch } from '../game/botMatch';
-import type { CategoryId, ThemeId } from '../types';
+import { getSharedGame } from '../game/sharedGame';
+import type { ThemeGuessGame } from '../game/ThemeGuessGame';
+import type { ThemeId } from '../types';
+import type { PreviewFlowElements } from './previewFlow';
+import { runThemePreview } from './previewFlow';
import type { ThemeGrid } from './themeGrid';
/** Reuses `engine/bot`'s time-mode vocabulary so a solo config can be
@@ -16,6 +21,7 @@ export interface SoloConfig {
themeId: ThemeId;
snippetIndex: number;
timeMode: SoloTimeMode;
+ themeMode: SoloThemeMode;
opponent: SoloOpponent;
botDifficulty: BotDifficulty;
}
@@ -31,33 +37,36 @@ export interface SoloConfigFlowElements {
botDifficultyWrap: HTMLElement;
playBtn: HTMLButtonElement;
/** Empty mount point (outside the menu modal) the inline timer/mode
- * badge HUD renders itself into. */
+ * badge/quit-button HUD renders itself into. */
timerMount: HTMLElement;
+ /** Topbar badge showing the active theme's name. */
+ themeNameBadge: HTMLElement;
+ /** Mounts for the pre-round "flash the real theme" overlay. */
+ preview: PreviewFlowElements;
}
-export interface SoloConfigFlow {
- /** Starts the inline countdown/elapsed-time HUD for a just-started
- * round. Call once the round is actually live (e.g. after the theme
- * preview finishes), not at Play-click time. Also the "round is live"
- * signal this module uses internally to start (or, for 'Alone',
- * tear down) the bot opponent's split-view pane — see `handleLocalAssignment`. */
- startTimer(mode: SoloTimeMode): void;
- /** `ThemeGuessGame`'s `onCategoryAssigned` callback target. Wire this
- * once, at the game's first construction (see `MultiplayerMatch`'s
- * two-phase-init doc for the pattern this mirrors) — it's a stable
- * dispatch that forwards to whichever bot round `startTimer` most
- * recently started, and is a no-op for 'Alone' rounds. */
- handleLocalAssignment(id: CategoryId, hex: string): void;
+export interface SoloConfigFlowHooks {
+ /** Hides every top-level overlay so the board is visible. */
+ hideMenu: () => void;
+ /** Shows the main menu (also hides the result modal, if open — see
+ * main.ts's `showView`). */
+ showMenu: () => void;
+}
+
+function requireEl(id: string): T {
+ const el = document.getElementById(id);
+ if (!el) throw new Error(`Missing required element #${id}`);
+ return el as T;
}
const TIME_MODE_DEADLINE_MS: Record, number> = {
- '1min': 60_000,
'2min': 120_000,
+ '4min': 240_000,
};
const TIME_MODE_LABEL: Record = {
- '1min': '1 MIN',
'2min': '2 MIN',
+ '4min': '4 MIN',
none: 'NO LIMIT',
};
@@ -76,21 +85,33 @@ function checkedValue(inputs: HTMLInputElement[], fallback: T)
return (inputs.find((input) => input.checked)?.value as T | undefined) ?? fallback;
}
-/** Minimal inline countdown (1/2 minute modes) or elapsed-time counter
- * ("no limit") plus a mode badge, rendered into `mount`. Purely a
- * display: it never forces a reveal, since `ThemeGuessGame.reveal()`
- * stays manually triggered by the existing Reveal button in every time
- * mode, "no limit" included.
+interface TimerHud {
+ /** Starts (or restarts) the countdown/elapsed-time display. `onExpire`,
+ * on the two clocked modes only, fires exactly once when the countdown
+ * reaches zero. */
+ start(mode: SoloTimeMode, onExpire?: () => void): void;
+ /** Stops the ticking interval without hiding the HUD. */
+ stop(): void;
+ /** Stops and hides the whole HUD (including the quit button). */
+ hide(): void;
+}
+
+/** Minimal inline countdown (2/4 minute modes) or elapsed-time counter
+ * ("no limit") plus a mode badge and an always-available Quit button,
+ * rendered into `mount`.
* TODO: replace with shared timerHud module once available. */
-function createTimerHud(mount: HTMLElement): SoloConfigFlow['startTimer'] {
+function createTimerHud(mount: HTMLElement, onQuit: () => void): TimerHud {
mount.innerHTML = `
00:00
+
`;
const root = mount.querySelector('.solo-timer-hud')!;
const badge = mount.querySelector('.solo-timer-badge')!;
const clock = mount.querySelector('.solo-timer-clock')!;
+ const quitBtn = mount.querySelector('.solo-timer-quit')!;
+ quitBtn.addEventListener('click', onQuit);
let intervalId: number | null = null;
function stop(): void {
@@ -100,7 +121,7 @@ function createTimerHud(mount: HTMLElement): SoloConfigFlow['startTimer'] {
}
}
- return function startTimer(mode: SoloTimeMode): void {
+ function start(mode: SoloTimeMode, onExpire?: () => void): void {
stop();
root.classList.remove('hidden');
badge.textContent = TIME_MODE_LABEL[mode];
@@ -113,31 +134,52 @@ function createTimerHud(mount: HTMLElement): SoloConfigFlow['startTimer'] {
return;
}
const remaining = TIME_MODE_DEADLINE_MS[mode] - elapsed;
- clock.textContent = formatClock(remaining);
- if (remaining <= 0) stop();
+ clock.textContent = formatClock(Math.max(0, remaining));
+ if (remaining <= 0) {
+ stop();
+ onExpire?.();
+ }
}
tick();
intervalId = window.setInterval(tick, 250);
- };
+ }
+
+ function hide(): void {
+ stop();
+ root.classList.add('hidden');
+ }
+
+ return { start, stop, hide };
}
/** Wires the Solo menu section's config form (time mode / theme mode /
* opponent, with the theme grid and bot difficulty picker revealed
- * conditionally) and its inline timer HUD. Fires `onStart` with the
- * resolved config once the player clicks Play. */
+ * conditionally), its inline timer/quit HUD, and every shared
+ * result-modal button ("Keep Comparing" / "Play Again") for as long as a
+ * solo round is the thing driving the shared board — see `soloActive`. */
export function createSoloConfigFlow(
elements: SoloConfigFlowElements,
themeGrid: ThemeGrid,
- onStart: (config: SoloConfig) => void,
-): SoloConfigFlow {
+ hooks: SoloConfigFlowHooks,
+): void {
const themeModeInputs = radios(elements.section, 'solo-theme-mode');
const opponentInputs = radios(elements.section, 'solo-opponent-mode');
const timeModeInputs = radios(elements.section, 'solo-time-mode');
const botDifficultyInputs = radios(elements.section, 'solo-bot-difficulty');
- const startTimerHud = createTimerHud(elements.timerMount);
+ const closeResultBtn = requireEl('close-result-btn');
+ const playAgainBtn = requireEl('play-again-btn');
const botMatch = new BotMatch();
+
let lastConfig: SoloConfig | null = null;
+ let activeGame: ThemeGuessGame | null = null;
+ /** True from the moment a solo round starts until the player quits —
+ * guards the shared, page-global result-modal buttons and the Quit
+ * button so a stale click from a leftover matchmaking/private-room
+ * result doesn't reroute solo state, and vice versa. */
+ let soloActive = false;
+
+ const timerHud = createTimerHud(elements.timerMount, quit);
function syncThemePicker(): void {
const mode = checkedValue(themeModeInputs, 'random');
@@ -154,8 +196,42 @@ export function createSoloConfigFlow(
syncThemePicker();
syncBotDifficulty();
+ /** Starts (or restarts) a live round from a fully-resolved config:
+ * flashes the theme preview, then swaps the shared `ThemeGuessGame`
+ * onto it, arms the timer HUD, and starts/stops the bot opponent. */
+ function beginRound(config: SoloConfig): void {
+ lastConfig = config;
+ soloActive = true;
+ hooks.hideMenu();
+ runThemePreview(elements.preview, config.themeId, () => {
+ const game = getSharedGame(config.themeId, config.snippetIndex, (id, hex) => botMatch.handleLocalAssignment(id, hex));
+ activeGame = game;
+ botMatch.bindGame(game);
+ elements.themeNameBadge.textContent = THEMES[config.themeId].name;
+ sound.playApply();
+
+ const vsBot = config.opponent === 'bot';
+ // Bot rounds end themselves at the same deadline (`BotMatch.start`'s
+ // own `ROUND_DEADLINE_MS` timeout) and render the two-column
+ // scoreboard; forwarding `onExpire` there too would race it into
+ // also opening the solo (single-score) result view. Alone rounds
+ // have nothing else watching the clock, so they need it.
+ timerHud.start(config.timeMode, vsBot ? undefined : () => game.reveal());
+ if (vsBot) {
+ botMatch.start({
+ themeId: config.themeId,
+ snippetIndex: config.snippetIndex,
+ timeMode: config.timeMode,
+ difficulty: config.botDifficulty,
+ });
+ } else {
+ botMatch.stop();
+ }
+ }, () => sound.playOpen());
+ }
+
elements.playBtn.addEventListener('click', () => {
- const timeMode = checkedValue(timeModeInputs, '1min');
+ const timeMode = checkedValue(timeModeInputs, '2min');
const themeMode = checkedValue(themeModeInputs, 'random');
const opponent = checkedValue(opponentInputs, 'alone');
const botDifficulty = checkedValue(botDifficultyInputs, 'easy');
@@ -165,32 +241,55 @@ export function createSoloConfigFlow(
? themeGrid.selected
: themeIds[Math.floor(Math.random() * themeIds.length)];
- lastConfig = {
+ beginRound({
themeId,
snippetIndex: pickRandomSnippetIndex(),
timeMode,
+ themeMode,
opponent,
botDifficulty,
- };
- onStart(lastConfig);
+ });
});
- return {
- startTimer(mode) {
- startTimerHud(mode);
- if (lastConfig?.opponent === 'bot') {
- botMatch.start({
- themeId: lastConfig.themeId,
- snippetIndex: lastConfig.snippetIndex,
- timeMode: lastConfig.timeMode,
- difficulty: lastConfig.botDifficulty,
- });
- } else {
- botMatch.stop();
- }
- },
- handleLocalAssignment(id, hex) {
- botMatch.handleLocalAssignment(id, hex);
- },
- };
+ /** "Keep Comparing": lets the player keep tweaking colors after a
+ * reveal instead of being stuck admiring the score. Unlocks the
+ * Reveal button for the rest of the round (`enableExtendedPlay`) and
+ * restarts the HUD in elapsed-time "NO LIMIT" mode — an explicit
+ * countdown still running would otherwise force another auto-reveal
+ * mid-tweak (see `beginRound`'s `onExpire`). */
+ function keepComparing(): void {
+ if (!soloActive || !activeGame) return;
+ activeGame.enableExtendedPlay();
+ timerHud.start('none');
+ }
+
+ /** "Play Again": a random-theme round immediately rerolls a new theme
+ * and snippet and starts over, with no extra input needed. A
+ * chosen-theme round instead reopens the menu (already sitting on
+ * "choose" with the theme grid visible) so the player picks their
+ * next theme, same as starting any other chosen-theme round. */
+ function playAgain(): void {
+ if (!soloActive || !lastConfig) return;
+ if (lastConfig.themeMode === 'choose') {
+ hooks.showMenu();
+ return;
+ }
+ const themeIds = Object.keys(THEMES) as ThemeId[];
+ const themeId = themeIds[Math.floor(Math.random() * themeIds.length)];
+ beginRound({ ...lastConfig, themeId, snippetIndex: pickRandomSnippetIndex() });
+ }
+
+ /** Ends the active solo round and returns to the main menu — wired to
+ * the timer HUD's own Quit button, which is visible for as long as a
+ * solo round is live (menu, mid-round, and after a reveal alike). */
+ function quit(): void {
+ if (!soloActive) return;
+ soloActive = false;
+ botMatch.stop();
+ timerHud.hide();
+ hooks.showMenu();
+ }
+
+ closeResultBtn.addEventListener('click', keepComparing);
+ playAgainBtn.addEventListener('click', playAgain);
}