Match Screen — State & Data Flow
The match screen is the most complex part of the client. This page covers the type shapes, how data flows from the server to the display layer, and how Match.tsx coordinates its three hooks.
Type Reference
GameState
Received from the server on every gameState socket event.
typescript
interface GameState {
id: string; // match ID
players: Player[]; // both players; bot uses id "bot", name "Roboto"
currentTurn?: string; // userId of the player whose turn it is
currentPlayer?: string; // alias for currentTurn (older field)
status:
| "waiting"
| "playing"
| "active"
| "completed"
| "abandoned"
| "finished";
moves?: GameMove[]; // ordered move history
gameMode?: string; // "default" | "lite" | "singleplayer"
lastBandName?: string | null;
maxTurnTimeMs?: number; // turn duration in ms (from appConfig)
currentTimer?: number | null;
currentRound?: number; // current round index from server
winner?: string | null; // userId of winner, null if ongoing or draw
createdAt?: string;
powerupsUsed?: Record<string, PowerupType[]>; // playerId → types used this match
activeScramblerLetter?: string; // set while Scrambler override is pending
zappedBands?: string[]; // normalized band names re-allowed for play
}Finished statuses: "completed", "abandoned", "finished" — all three are treated as game-over by the client. Defined in utils/matchHelpers.ts as FINISHED_STATUSES.
GameMove
Each entry in GameState.moves. Represents a single played band.
typescript
interface GameMove {
id?: string;
bandId?: string; // alternative ID field (same value, different name)
playerId: string;
moveType: string; // always "playBand" for band submissions
data?: { bandName?: string; submitted?: string };
bandName?: string | null; // canonical Spotify name (duplicates data.bandName)
timestamp?: string;
round?: number; // round index for ordering
valid?: boolean; // false = illegal move (still recorded); absent/true = legal
invalidReason?: string; // set when valid === false
isRevealed?: boolean; // whether the opponent has revealed this band
}GameBand
Received from the server on gameBands events. Filtered per player — the opponent's unrevealed bands arrive with bandName: null.
typescript
interface GameBand {
id: string;
bandName: string | null; // null = hidden (opponent hasn't revealed yet)
playerId: string;
isValid?: boolean; // false = illegal move
invalidReason?: string | null;
revealed?: boolean;
timestamp?: string;
round?: number;
status?: string; // legacy: "illegal" (isValid: false is preferred)
}BandRow
The display-ready type computed by buildDisplayBands. This is what Match.tsx renders — never GameBand directly.
typescript
interface BandRow {
id: string;
renderKey?: string; // stable key for React: "{playerId}:{round}:{occurrence}"
playerId: string;
playerName: string; // resolved display name
bandName: string | null; // canonical name (or null when hidden)
submitted?: string | null; // what the user actually typed (shown when different from canonical)
isRevealed: boolean;
isOwn: boolean; // true if playerId === currentUserId
isValid: boolean;
invalidReason: string | null;
sortTime: number; // timestamp as ms epoch for ordering
round: number;
}Data Pipeline: Server → Display
text
Socket events
│
├── gameState ──► GameState ──────────────────────────────────────┐
│ │
└── gameBands ──► GameBand[] ──► buildDisplayBands() ──► BandRow[]
│
also reads:
- currentUserId
- lastMoveError
- optimisticRevealedIdsbuildDisplayBands() — utils/buildDisplayBands.ts
Transforms raw server data into render-ready rows. Four pipeline steps, run in order:
Build rows from
gameBands(preferred) orgameState.moves(fallback when bands not yet received). Resolves player names, merges reveal state from bothgameBandsandgameState.moves, and recovers the canonical band name from move data when aGameBandarrives hidden then gets revealed.groupByRoundThenSortByTime— groups byround, sorts each group bysortTime. Ensures bands appear in chronological round order regardless of socket delivery order.filterInvalidVisibility— hides illegal moves from the list unless the game is over, or there is a later valid revealed band (so the chain stays readable). Also auto-reveals opponent illegal moves (since a hidden invalid band makes no sense).withStableRenderKeys— assignsrenderKey = "{playerId}:{round}:{occurrence}"for stable React list keys.
Optimistic updates
Two optimistic layers sit on top of buildDisplayBands:
Optimistic reveal (optimisticRevealedIds: Set<string>) — when the user taps to reveal an opponent's band, the band ID is added to this set immediately. buildDisplayBands treats any ID in the set as revealed. The ID is removed once the server confirms the reveal in the next gameBands event.
Pending own move (pendingMoveBandName: string | null) — when the user submits a band, it appears immediately as a valid confirmed-looking row before the server responds. useMatchState appends a synthetic BandRow to displayBands for the duration. It is removed once baseDisplayBands contains a server-confirmed row with the same normalised name.
Hook Architecture in Match.tsx
Match.tsx composes three hooks:
text
Match.tsx
│
├── useMatchState() Core match state, socket subscriptions, all handlers
├── useBotMove() Singleplayer bot simulation
└── useChallengeStart() Challenge-with-first-move entry flowuseMatchState — hooks/useMatchState.ts
Owns everything related to the live match: socket subscriptions, timer countdown, optimistic state, and all action handlers. Returns UseMatchStateResult (see below).
Socket events subscribed:gameState, gameBands, matchFound, queueStatus, moveError, revealError, timerStarted, timerCleared, turnTimeout, gameAbandoned
Timer logic: timerStarted sets timerEndAtMs (absolute epoch ms). A 250 ms setInterval drives timerSecondsLeft countdown. When timerSecondsLeft hits 0 and it's the current user's turn, the hook emits clientTimeout once (guarded by timeoutSentRef).
Load timeout: After joinGame is emitted, an 8-second timeout (LOAD_MATCH_TIMEOUT_MS) sets loadMatchError if gameState hasn't arrived. Cleared on first valid gameState.
Cache invalidation: When a match transitions from active → finished, invalidateMatchesCache() and invalidateStatisticsCache() fire so stale REST data is re-fetched.
Derived booleans (computed, not stored in state):
| Field | Derivation |
|---|---|
isWaitingInQueue | queueStatus.status === "waiting" && !gameState |
isLoadingMatch | matchId && userId && !gameState && !isWaitingInQueue |
isGameOver | gameOverReason != null or status in FINISHED_STATUSES |
isMyTurn | gameState.currentTurn === userId |
hasUnrevealedOpponentMove | any displayBands row with !isOwn && !isRevealed |
currentRoundNumber | completed full rounds + 1 |
timeLeftDisplay | timerSecondsLeft or maxTurnTimeMs / 1000 when no timer |
usedPowerups | powerup types already activated by the current player |
zapperActive | whether Zapper targeting mode is on |
scramblerLetter | active scrambled letter override, or null |
useBotMove — hooks/useBotMove.ts
Handles singleplayer mode. Simulates the bot "thinking" and playing. Exposes botIsTyping (boolean) and botBandReveal (string | null) — the bot's next band name. Match.tsx renders the opponent typing indicator and the bot's revealed band using these fields.
Bot failure probability increases each round (from BOT_FAIL_BASE to BOT_FAIL_MAX) making the bot progressively harder to beat.
useChallengeStart — hooks/useChallengeStart.ts
Handles the entry flow when the match screen is opened from a challenge (no matchId yet, only recipientId). Manages sending sendChallengeWithFirstMove and waiting for challengeAccepted before the game begins.
Match.tsx Sub-components
| Component | Purpose |
|---|---|
MatchHeader | Round counter, timer display, quit button |
MatchInputBar | Band name text input + submit button |
BandRow | Single band bubble — own vs opponent, revealed vs hidden |
MessageBubble | Opponent typing indicator (three-dot animation) |
MatchQueueScreen | Full-screen waiting state while in queue |
MatchChallengeStartScreen | Full-screen waiting state for challenge acceptance |
MatchGameOver | In-list game-over banner showing winner/loser |
MatchEndModal | Post-game modal with result + rematch button |
MatchErrorBanner | Inline error strip (move rejected, reveal failed) |
MatchErrorScreen | Full-screen error (load failed, socket error) |
WordEffect | Floating "+10 XP" / affirmation animation on valid move |
Word effects
When the user submits a valid move, Match.tsx fires a WordEffect overlay 25% of the time (random). The effect spawns at the position of the last own band bubble (measured with measureInWindow), rises, and fades. The message is randomly picked from VALID_MOVE_MESSAGES ("Awesome", "Legendary", "Deep Cut", etc.).