Skip to content

Timers & Abandonment

Turn Timer

Duration

Game modemaxTurnTimeMs
'default'20 000 ms (20 s)
'lite'liteRoundLengthMs from appConfig (default 20 000 ms)
'singleplayer'20 000 ms — but timer is skipped for bot turns

maxTurnTimeMs is set once at match creation and stored on the match document. It is the match-level default and never changes mid-match. Per-turn extensions (e.g. Add Time powerup) are tracked separately in currentTimer.maxTurnTime.

When the Timer Starts

The timer does not start at the beginning of a player's turn. It starts when the opponent reveals the previous band via the revealBand event.

text
Player A submits band  →  stored, turn advances to B
Player B sends revealBand  →  timer starts for B
Player B submits band (or times out)  →  timer cleared

This means a player can sit on an unrevealed band indefinitely without the clock running — revealing is a deliberate commitment.

Exception: In singleplayer mode, the timer is never started for the bot player.

Timer Object

typescript
interface TurnTimer {
  playerId: string;
  turnStartedAt: Date;   // exact moment startTurnTimer() was called
  maxTurnTime: number;   // ms; may be extended beyond maxTurnTimeMs by Add Time
}

Stored as gameState.currentTimer. Cleared (set to undefined) when a valid move is made.

Timer Events

EventDirectionPayloadWhen emitted
timerStartedServer → Client{ playerId, turnStartedAt, maxTurnTime }On reveal (immediately, before band fetches) and after any powerup move that has an active timer
timerClearedServer → Client{ playerId }After any non-powerup move succeeds
turnTimeoutServer → Client{ playerId }After a validated client timeout claim

Timer Lifecycle in Detail

Server side (GameService + game.gateway.ts)

  1. revealBandstartTurnTimer(gameId, playerId)
    • Creates { playerId, turnStartedAt: new Date(), maxTurnTime: gameState.maxTurnTimeMs }
    • Saves to DB + in-memory cache
    • Returns timerInfo
  2. Gateway emits timerStarted immediately (before the async getGameBandsForPlayer calls) so the event is never delayed behind concurrent socket handlers.
  3. On makeMove:
    • If moveType === 'usePowerup': does not emit timerCleared; emits timerStarted with the current (possibly extended) currentTimer so clients resync.
    • Otherwise: emits timerCleared.

Client side (useMatchState.ts)

The client maintains three related pieces of state:

StateTypeDescription
timerEndAtMsnumber | nullAbsolute epoch ms when this turn expires (turnStartedAt + maxTurnTime)
timerSecondsLeftnumber | nullSeconds remaining, updated every 250 ms by a setInterval
currentTimerMaxMsnumberThe current max for this turn (may exceed maxTurnTimeMs after Add Time)

timerEndAtMsRef is a ref kept in sync with timerEndAtMs so it can be read synchronously inside callbacks.

onTimerStarted handler

text
1. Compute endAt = turnStartedAt + maxTurnTime
2. Staleness guard: if timerEndAtMsRef.current != null && endAt < timerEndAtMsRef.current → discard
   (Prevents a delayed revealBand timerStarted from overriding an extended Add Time deadline)
3. setCurrentTimerMaxMs(maxMs)
4. left = ceil((endAt - now) / 1000)
5. if left <= 2 → clear timer state, return (avoids starting a near-expired countdown)
6. setTimerEndAtMs(endAt), start setInterval(tick, 250)

onTimerCleared handler

Clears timerEndAtMs, timerSecondsLeft, and resets currentTimerMaxMs back to the match default (maxTurnTimeMsRef.current).


Known Race Condition (resolved)

Problem: handleRevealBand in the gateway awaits multiple async DB calls (getGameBandsForPlayer for each player) before emitting timerStarted. If the player uses a powerup during those awaits, Node's event loop processes handleMakeMove concurrently. The powerup's timerStarted (with the extended deadline) can arrive on the client before the reveal's timerStarted (with the original 20 s deadline). When the reveal's delayed event arrives, it overrides the extended deadline and resets the display.

Fix:

  • Server: timerStarted from revealBand is now emitted before the async band fetches.
  • Client: onTimerStarted has a staleness guard — if the incoming endAt is earlier than timerEndAtMsRef.current, the event is discarded.

Timeout Handling

Three paths can end a turn by timeout, listed in priority order:

Path 1: Client clientTimeout event (fastest)

The client proactively notifies the server when the local countdown hits zero:

text
Client sends: clientTimeout { gameId, playerId }

Server validates with a 1 000 ms grace period to account for network latency:

text
timeElapsed >= maxTurnTime - 1000ms  →  legitimate, game ends
timeElapsed < maxTurnTime - 1000ms  →  rejected, emits timeoutError

Path 2: Server-side detection on move submission

If the client submits a move after the timer has already expired on the server:

text
timeElapsed = now - timer.turnStartedAt
if (timeElapsed > maxTurnTime) → timeout, other player wins

The move is not recorded; the game ends due to timeout instead.

Path 3: Server-side setTimeout fallback

When startTurnTimer runs, the server schedules its own setTimeout for maxTurnTime + 500 ms. If neither Path 1 nor Path 2 fires first (e.g. the player exited the match screen so the client timer never fired), this fires endGameByTimeout directly on the server. The gateway subscribes via GameService.onGameEnded() and broadcasts the updated gameState + turnTimeout to the room — which allows the Matches list to update in real time even when the player isn't in the match screen.

endGameByTimeout guards against double-finalization: if the game is already completed or abandoned when Path 3 fires, it returns a no-op.

Path 3 also covers re-entering an expired match: getGameState checks for an expired currentTimer and finalizes the game inline before returning the state.

Known limitation of Path 3: The setTimeout handle lives in process memory. A server restart between the reveal and the timer firing loses the handle. Path 3 then only recovers when someone next fetches the game state. See Roadmap — BullMQ-Backed Turn Timers for the planned durable fix.

In all three cases the timed-out player loses and gameState.winner is set to the other player.


The Timer Bar Component (MatchTurnTimerBar)

The bar and numeric display are driven entirely by timerEndAtMs (not timerSecondsLeft), using its own 50 ms internal interval for smooth animation. It also takes timerMaxSeconds (from currentTimerMaxMs) to calculate the fill fraction.

text
remainingMs  = timerEndAtMs != null ? timerEndAtMs - now : timerSecondsLeft * 1000
fraction     = remainingMs / (timerMaxSeconds * 1000)   ← uses current-turn max, not match default
displayText  = ceil(remainingMs / 1000)

When timerEndAtMs is null (timer cleared), timerSecondsLeft is also null, so the bar falls back to showing the current max as a static label with a full bar.

Important: timerMaxSeconds must reflect the per-turn max (including Add Time extensions), not the static match maxTurnTimeMs. This is why currentTimerMaxMs is tracked separately and passed down from useMatchState.


Add Time Powerup — Timer Interaction

When Add Time is used:

  1. Client (optimistic, immediate): timerEndAtMs += 10 000, currentTimerMaxMs += 10 000, interval restarted with the new deadline. The user sees the count jump instantly.
  2. Server: currentTimer.maxTurnTime += 10 000 (capped at 60 000). A timerStarted event is emitted with the updated timer so clients resync. No timerCleared is sent.
  3. Client (resync): onTimerStarted receives the server's confirmed deadline. Because the optimistic update already set timerEndAtMsRef.current to the extended deadline, any event with an earlier endAt (e.g. a stale reveal event) is silently discarded.

The timerMaxSeconds bar max extends to match the new total, so the bar shows remainingMs / newMax — it doesn't jump to 100% just because the timer was extended.


Abandonment

A player can voluntarily abandon a match at any time by sending abandonGame.

text
Client sends: abandonGame { gameId, playerId }
  • Match status'abandoned'
  • Abandoning player is set as the loser
  • gameAbandoned { playerId } emitted to all players in the room
  • XP rewards fire (loser gets baseLossXp)

Abandonment is distinct from a timeout: it's immediate with no grace period check.


Pre-Move Validation Order

Before any move is processed, these checks run in order. Failing any emits moveError and does not record the move or end the game:

  1. Game exists
  2. Player is in the game (singleplayer: only human can submit; submitting for bot is allowed)
  3. It is the player's turn (currentTurn === playerId)
  4. Game status is 'active' or 'waiting'
  5. Timer has not expired (skipped for bot in singleplayer)

Only after all five pass does band-name validation run.