Scoring bug (priority fix): MultiplayerMatch/BotMatch add a `hidden`
class to the solo result panel (#solo-score-meter-wrap, #score-verdict,
#score-breakdown, #compare-toggle) once a networked/bot round's real
two-column scoreboard takes over, but no CSS rule ever paired `.hidden`
with those selectors (every other hideable element in this codebase has
its own scoped rule) -- the class was inert, so the solo panel stayed
visible forever, stacked above the mp-scoreboard, making both look like
they showed the same/wrong numbers. Added the missing scoped rules.
Reveal button (online play): clicking Reveal Match used to run
ThemeGuessGame's own instant local reveal() even in a live multiplayer
round -- unrelated to the server, and the direct trigger of the above
scoring bug. Added a revealOverride hook on ThemeGuessGame; while a
multiplayer round is live, MultiplayerMatch routes the click through a
new round:revealVote message instead. The round now only ends early
once *both* players vote (server tracks votes per session and reveals
immediately on a mutual vote); a single vote relays round:revealVoteUpdate
to the opponent, and the voter sees "Waiting for opponent to vote for
reveal..." below the button.
Theme preview timing: matchmaking and private-room rounds never ran the
10s theme-preview flash solo/bot rounds already have, so a round's real
colors never appeared before painting started. Added it to both flows
(ui/matchmakingFlow.ts, ui/privateRoomFlow.ts, including every match in
a private room's up-to-5-match series, not just the first). The
backend's round:start.endsAt now bakes in a matching 10s PREVIEW_MS
head start (session.js) so the preview never eats into actual painting
time.
Private room results view: a private room's next match used to start
within milliseconds of the previous one's reveal, cutting the result
modal off almost instantly. room.js now delays round:nextMatch (and the
series-ending room:closed) by RESULT_VIEW_MS (10s) after round:reveal so
both players actually get to look at a match's result.
Theme grid / vote UI: the theme-vote modal's grid was missing the
theme-grid class that actually applies the 2-column CSS grid (dead CSS
already anticipated the combination), so vote theme cards rendered in
whatever the browser's default block flow was. Added the class, and
added a distinct (pink, dashed outline) opponent-pick highlight to
ui/themeGrid.ts so the opponent's live vote pick is visually distinct
from the local player's own selection, keeping the existing 'Opponent
chose <theme>' text.
PROTOCOL.md updated for all wire-visible changes: endsAt formula,
round:revealVote/round:revealVoteUpdate, and the round:nextMatch/
room:closed timing.
- Theme-picker modal always opens with a fixed default highlighted
card instead of the last-played theme.
- Removed the change-theme-btn entirely; Back to Menu is now the only
result-modal button shown while a solo/bot round is active, and does
what Change Theme used to (go to the main menu) plus solo cleanup.
- Solo theme choice moves out of the settings menu into its own
post-Play modal; Play Again reopens it for chosen-theme rounds and
instantly rerolls (never repeating the last theme) for random rounds.
- Last solo/bot theme played persists in localStorage so a fresh
random pick or page reload doesn't immediately repeat it.
- Result modal swaps Change Theme for a Back to Menu button for as
long as a solo/bot round is active.
- Darkened the opponent-progress fill color (renderer.ts).
- Bump time modes from 1/2 to 2/4 minutes across backend protocol, bot,
timer HUD, and net message types (more room to read a snippet before
guessing).
- Extract per-category token counts/weights into a shared
engine/categoryStats.ts, replacing three independently-drifting
copies of the same formula in ThemeGuessGame/botMatch/multiplayerMatch.
- Switch scoring.ts from a plain weighted average to a power-mean
combine so whiffing a high-weight category can't be masked by acing
the rest; retune colorUtils' similarity falloff divisor to match.
- Update solo config flow, opponent view, and renderer for the new time
options and scoring feedback.
- frontend/src/ui/privateRoomFlow.ts: create-room sub-flow (time/theme
mode form, large shareable code, copy-to-clipboard with graceful
no-op fallback, waiting-for-opponent state) and enter-code sub-flow
(numeric input + inline room:error retry, no dead end). Handles
room:joined for both themeMode:'random' (straight into the live
match) and 'chosen' (vote-wait placeholder, TODO(#16) for the real
vote modal). Listens for round:nextMatch to show a brief 'Match N of
5' transition, and room:closed to show the reason and return to the
main menu. A room with no active session yet (waiting alone, or
mid-vote) is abandoned via a socket disconnect/reconnect cycle, since
player:quit is a no-op server-side without a session to attach to.
- frontend/src/game/multiplayerMatch.ts: MultiplayerMatch now owns
#split-view visibility (shown in startRound, hidden in
teardownRound), mirroring botMatch.ts's existing convention — nothing
previously made the opponent pane visible for a real multiplayer
match.
- frontend/src/net/client.ts: exports a shared `client` GameClient
singleton (connects on import) so private rooms and matchmaking use
one socket instead of each constructing their own.
- frontend/index.html: adds the #private-room-mount overlay container.
- frontend/src/style.css: private-room-* styles for the code display,
join form, and error/status text.
- frontend/src/main.ts: replaces only the enterCodeBtn/createRoomBtn
TODO stubs with real privateRoomFlow wiring; findMatchBtn line
untouched.
Wires the Matchmaking menu section's Find Match button to the real
queue:join/queue:leave flow: a searching state with cancel, the
queue:banned countdown (banBanner.ts) with Find Match hidden until it
expires, a brief assigned-mode reveal on match:found, and the handoff
into MultiplayerMatch's two-phase init once round:start is known.
After a match ends (round:reveal + result dismissal, local quit, or
opponent quit via room:closed) the flow returns to the matchmaking
menu without re-queuing.
Also adds a shared GameClient singleton (net/client.ts) so every UI
flow that talks to the server reuses one connection.
Note: backend/src/matchmaking/queue.js actually sends round:start
before match:found (the reverse of PROTOCOL.md's documented order),
discovered via live two-tab testing against the real backend;
matchmakingFlow.ts tracks both messages independently so either
arrival order works.
- index.html/style.css: destructive-styled #match-quit-btn in the in-match
HUD row, two-click arm/confirm (no heavyweight modal).
- multiplayerMatch.ts: wires the button (send player:quit, tear down
timer/listeners/opponent pane immediately, no server reply awaited),
adds an optional onQuit constructor callback for a future host
(#14/#15) to route back to the main menu -- nothing mounts
MultiplayerMatch yet, so this ticket's own quit-button wiring is what
exercises it.
- ui/banBanner.ts (new): standalone renderBanBanner(container,
bannedUntil) countdown, ready for #14 to mount into #menu-matchmaking.
Not wired anywhere yet: per session.js/queue.js, quitting a
matchmaking match pushes nothing to the quitter directly -- the only
wire message carrying ban info is queue:banned, sent in reply to a
later queue:join while banned.
Verified against a real backend + real browser tab driving
MultiplayerMatch through an actual matchmaking match (paired against a
raw ws opponent): quit sends player:quit, opponent gets
room:playerLeft + room:closed(reason:quit) with no ban message; a
follow-up queue:join from the quitter correctly receives queue:banned
(bannedUntil ~120s out) and renderBanBanner counts it down live.
Repeated via room:create/room:join (private room): identical
teardown/onQuit behavior, opponent gets zero ban-related messages, and
the quitter's earlier matchmaking ban is left completely unchanged.
New multiplayerMatch.ts (MultiplayerMatch class) drives one round: syncs
ThemeGuessGame/OpponentView to a round:start payload, relays local
category assignments as round:progress, forwards opponent round:progress
to OpponentView, drives timerHud's countdown (submitting the local color
map as round:submit at expiry), and renders a two-column scoreboard on
round:reveal via computeMatchResult.
ThemeGuessGame gains an optional onCategoryAssigned?(id, hex) constructor
callback, invoked as the last step of applyColor.
timerHud's TimerHudOptions gains an optional onExpire?() callback on the
countdown variant, fired once when endsAt passes.
resultView.ts gains renderMultiplayerResult() for the two-column
you/opponent scoreboard + winner banner (equal overall -> explicit draw).
index.html/style.css: additive #mp-scoreboard/#mp-winner-banner/#mp-you-*/
#mp-opponent-* markup and .mp-* styles; existing single-player result
markup untouched aside from adding id="solo-score-meter-wrap" so it can
be toggled off during a multiplayer reveal.
Replace the single pick-a-theme-and-start intro modal with a three-section
menu (Solo/Private/Matchmaking). Solo keeps the existing gameplay reachable
via a direct Play button; Private (Enter Code/Create Room) and Matchmaking
(Find Match) are stubbed with console.log no-ops pending tickets #17/#15/#14.
main.ts gains a small showView(name) helper that toggles .hidden across the
menu/result overlay screens, keeping it a thin composition root.
Splits the single game.js god-class into layered modules (types, data,
engine, game, preview, ui, main) and converts every source file to
TypeScript, built and served by Vite instead of a plain static server.
No behavior change: verified against the previous JS version via a full
play-through (theme select, preview countdown, coloring, reveal) in a
real browser.