Skip to content

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
                                        - optimisticRevealedIds

buildDisplayBands()utils/buildDisplayBands.ts

Transforms raw server data into render-ready rows. Four pipeline steps, run in order:

  1. Build rows from gameBands (preferred) or gameState.moves (fallback when bands not yet received). Resolves player names, merges reveal state from both gameBands and gameState.moves, and recovers the canonical band name from move data when a GameBand arrives hidden then gets revealed.

  2. groupByRoundThenSortByTime — groups by round, sorts each group by sortTime. Ensures bands appear in chronological round order regardless of socket delivery order.

  3. 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).

  4. withStableRenderKeys — assigns renderKey = "{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 flow

useMatchStatehooks/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):

FieldDerivation
isWaitingInQueuequeueStatus.status === "waiting" && !gameState
isLoadingMatchmatchId && userId && !gameState && !isWaitingInQueue
isGameOvergameOverReason != null or status in FINISHED_STATUSES
isMyTurngameState.currentTurn === userId
hasUnrevealedOpponentMoveany displayBands row with !isOwn && !isRevealed
currentRoundNumbercompleted full rounds + 1
timeLeftDisplaytimerSecondsLeft or maxTurnTimeMs / 1000 when no timer
usedPowerupspowerup types already activated by the current player
zapperActivewhether Zapper targeting mode is on
scramblerLetteractive scrambled letter override, or null

useBotMovehooks/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.

useChallengeStarthooks/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

ComponentPurpose
MatchHeaderRound counter, timer display, quit button
MatchInputBarBand name text input + submit button
BandRowSingle band bubble — own vs opponent, revealed vs hidden
MessageBubbleOpponent typing indicator (three-dot animation)
MatchQueueScreenFull-screen waiting state while in queue
MatchChallengeStartScreenFull-screen waiting state for challenge acceptance
MatchGameOverIn-list game-over banner showing winner/loser
MatchEndModalPost-game modal with result + rematch button
MatchErrorBannerInline error strip (move rejected, reveal failed)
MatchErrorScreenFull-screen error (load failed, socket error)
WordEffectFloating "+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.).