ThemeGuessGame's constructor permanently binds click/mousemove/document
listeners onto the single shared #code-canvas/#category-panel. Solo,
matchmaking, and private-room flows each independently constructed their
own instance, so using more than one mode in the same page load (e.g.
Solo then Find Match) left multiple instances double-bound to the same
DOM, corrupting whichever mode was used first.
Add game/sharedGame.ts: a single lazily-constructed instance, reused via
setTheme/setSnippet and now-mutable onCategoryAssigned across every
flow. main.ts, matchmakingFlow.ts, and privateRoomFlow.ts now call
getSharedGame() instead of `new ThemeGuessGame(...)`.
Verified via CDP DOMDebugger.getEventListeners against a live session:
exactly one click/mousemove listener on #code-canvas after playing a
Solo round followed by a real matched Matchmaking round in the same
page load (previously would have been two of each).
- 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.
- 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.
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.