Timers & Abandonment
Turn Timer
Duration
| Game mode | maxTurnTimeMs |
|---|---|
'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 clearedThis 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
| Event | Direction | Payload | When emitted |
|---|---|---|---|
timerStarted | Server → Client | { playerId, turnStartedAt, maxTurnTime } | On reveal (immediately, before band fetches) and after any powerup move that has an active timer |
timerCleared | Server → Client | { playerId } | After any non-powerup move succeeds |
turnTimeout | Server → Client | { playerId } | After a validated client timeout claim |
Timer Lifecycle in Detail
Server side (GameService + game.gateway.ts)
revealBand→startTurnTimer(gameId, playerId)- Creates
{ playerId, turnStartedAt: new Date(), maxTurnTime: gameState.maxTurnTimeMs } - Saves to DB + in-memory cache
- Returns
timerInfo
- Creates
- Gateway emits
timerStartedimmediately (before the asyncgetGameBandsForPlayercalls) so the event is never delayed behind concurrent socket handlers. - On
makeMove:- If
moveType === 'usePowerup': does not emittimerCleared; emitstimerStartedwith the current (possibly extended)currentTimerso clients resync. - Otherwise: emits
timerCleared.
- If
Client side (useMatchState.ts)
The client maintains three related pieces of state:
| State | Type | Description |
|---|---|---|
timerEndAtMs | number | null | Absolute epoch ms when this turn expires (turnStartedAt + maxTurnTime) |
timerSecondsLeft | number | null | Seconds remaining, updated every 250 ms by a setInterval |
currentTimerMaxMs | number | The 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:
timerStartedfromrevealBandis now emitted before the async band fetches. - Client:
onTimerStartedhas a staleness guard — if the incomingendAtis earlier thantimerEndAtMsRef.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 timeoutErrorPath 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 winsThe 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:
- Client (optimistic, immediate):
timerEndAtMs += 10 000,currentTimerMaxMs += 10 000, interval restarted with the new deadline. The user sees the count jump instantly. - Server:
currentTimer.maxTurnTime += 10 000(capped at 60 000). AtimerStartedevent is emitted with the updated timer so clients resync. NotimerClearedis sent. - Client (resync):
onTimerStartedreceives the server's confirmed deadline. Because the optimistic update already settimerEndAtMsRef.currentto the extended deadline, any event with an earlierendAt(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:
- Game exists
- Player is in the game (singleplayer: only human can submit; submitting for bot is allowed)
- It is the player's turn (
currentTurn === playerId) - Game
statusis'active'or'waiting' - Timer has not expired (skipped for bot in singleplayer)
Only after all five pass does band-name validation run.