mancala/docs/specs/game-clock/design.md
Halit Aksoy cb34c03246 docs: add game clock spec
Each player has 5 minutes; the server keeps the time and a scheduled service ends games whose running clock reaches zero. Replaces the offline turn and inactivity rules.
2026-10-16 01:23:32 +03:00

9.8 KiB

Game clock: design

See requirements.md for what is built; requirement ids (R1.1, …) are referenced below. Builds on game-history (live game snapshots in Valkey, saved games in Postgres).

Decisions

Decision Reason
The server is the only authority on time; clients only display it Players cannot gain time by changing their device clock or client. A move arriving too late is refused on the server (R2.3).
The clock is stored as "time left when the turn started" plus turnStartedAt, not as a ticking value The state only changes on moves, so nothing is written per second; the Valkey snapshot that is already written on every move carries it. The time left at any moment is computed.
A scheduled service checks all live games every second, in addition to the check when a move arrives It ends games where nobody does anything (R2.2) and is simple to reason about and to restore after a restart. Because late moves are refused, checking once a second never changes a result; it only decides how soon the end is announced. Per-game timers were considered: more precise, but every move, end and restore would have to keep them in sync. A few hundred live games are a trivial loop.
The end reason "timeout" is reused It keeps its meaning: the player whose turn it was ran out of time. The database check constraint, results, texts and the recent games list need no change. Old saved timeouts came from the offline rule; the difference is not shown.
The offline rules from game-history (TURN_TIMEOUT_MS, INACTIVE_TIMEOUT_MS, offlineSince) are removed The running clock ends any game where the player to move is away within 5 minutes (R6.1), so the old rules can no longer trigger. One mechanism instead of two.
5 minutes, no increment, set by GAME_CLOCK_MS (default 300000) R1.1. The variable allows short clocks in tests and later tuning. The initial time travels with each game, so clients do not hard-code it and a running game keeps its time control if the setting changes.
Time while the backend is down is not charged (R5.1) A deploy must not make anyone lose. On restore, the running clock restarts from the time left at the start of the turn; the player gets back what they spent on that turn before the restart.
Messages carry the server's time Clients compute the offset between their clock and the server's and count down from it, so a wrong device clock does not matter (R3.3).
Animation and network delay are not compensated A long move animates for a few seconds (300 ms per step) on the next player's time. This is accepted for now, like network delay; time added per move is the fix if it turns out to matter (out of scope).

Shared types (packages/core)

// models/GameClock.ts
interface GameClock {
  initialMs: number;
  // Time left for each player. For the player whose turn it is: at turnStartedAt.
  player1Ms: number;
  player2Ms: number;
  // Server time when the running clock started; undefined when no clock runs (the game has ended).
  turnStartedAt?: number;
}
const CLOCK_LOW_TIME_MS = 30_000;   // R3.2
  • Game (live game messages) gains clock?: GameClock and serverTime?: number (server time when the message was built). Optional, so older servers and clients keep working.
  • GameEndMessage gains clock?: GameClock, the stopped clocks (R3.5).
  • MoveRecord gains remainingMs?: number, the mover's time left after the move (R4.1).
  • FinishedGameSummary gains clockInitialMs?: number; undefined for games saved before this feature (R4.3).

Helpers in packages/core/src/clock/, used by the backend, the web app and the mobile app:

  • getClockTimes(clock, runningSeat, now) → { player1Ms, player2Ms }, never below zero.
  • ClockSync: update(game, receivedAt) keeps the clock and the server offset (serverTime - receivedAt) from the last message; getTimes(now) returns both players' time and the running seat. Framework-free so both apps can use it.
  • getClockAtMove(record, n) → both players' time after move n of a saved game (n = 0: the initial time), from clockInitialMs and the movers' remainingMs; undefined for games without clock data. On a timeout, the last position shows the loser at zero.
  • formatClock(ms) → m:ss, rounded up so 0:00 only shows when the time is gone.

Backend

Modules

src/game/GameClock.ts            pure functions on GameClock
src/game/GameClockScheduler.ts   the scheduled service (R2.2)
src/db/migrations/002_clock.ts   clock_initial_ms column

GameClock.ts:

  • startClock(initialMs, now): both players at initialMs, turnStartedAt = now.
  • remainingMs(clock, seat, runningSeat, now): the stored time, minus now - turnStartedAt for the running seat.
  • chargeMove(clock, seat, now): deducts the turn's time from the mover and sets turnStartedAt = now, for a turn change and an extra turn alike (R1.2, R1.3). Returns the mover's time left.
  • stopClock(clock, runningSeat, now): deducts the running seat's time (at least down to zero) and clears turnStartedAt.
  • resumeClock(clock, now): sets turnStartedAt = now without deducting anything (R5.1).

Game lifecycle (GameManager)

  • BackendGame gains clock: GameClock and moveRemainingMs: number[], aligned with mancalaGame.history like moveTimes.
  • Start: startClock(config.gameClockMs, now).
  • Move: before the engine is called, if the mover is the turn player and their time is gone, the move is not played and the game ends with timeout (R2.3). After a move that was played (history grew), chargeMove and push the result to moveRemainingMs.
  • End (any reason): stopClock, then the final update and GameEndMessage carry the stopped clock.
  • endGamesOutOfTime(now) (public): ends with timeout every live game whose running clock is at zero; the winner is the opponent (R2.1). Each game is handled on its own, so an error in one is logged and does not stop the others.
  • Messages: toClientGame adds clock and serverTime. Reconnecting players already get on_game_update, so R3.4 needs nothing more.
  • Removed: checkTimeouts, the 10-second timer (startTimers / stopTimers), the offlineSince map and UserConnectionInfo.offlineSince; TURN_TIMEOUT_MS and INACTIVE_TIMEOUT_MS in core.

Scheduled service (GameClockScheduler)

class GameClockScheduler {
  constructor(gameManager: GameManager, intervalMs = CLOCK_CHECK_INTERVAL_MS /* 1000 */, clock = Date.now)
  start(): void   // setInterval → gameManager.endGamesOutOfTime(clock())
  stop(): void
}

src/index.ts starts it after restoreLiveGames() and stops it on shutdown, replacing gameManager.startTimers(). It only reads memory, so Postgres or Valkey being down does not affect it; ending a game saves it through GameRecorder with retry as today.

Restore (R5)

  • The snapshot becomes version: 2 with clock and moveRemainingMs. isLiveGameSnapshot accepts 1 and 2.
  • fromSnapshot: version 2 → resumeClock(snapshot.clock, now); version 1 → startClock(config.gameClockMs, now) and empty moveRemainingMs entries (R5.2).

Saving (R4.1)

  • toGameRecord sets clockInitialMs and each move's remainingMs (from moveRemainingMs).
  • Migration 002_clock: ALTER TABLE games ADD COLUMN clock_initial_ms integer; (NULL for older games). remainingMs lives in the existing moves jsonb. PostgresGameHistoryRepository writes and reads the column.

Configuration

GAME_CLOCK_MS (default 300000) in src/consts/config.ts, passed into GameManager.

Web app

  • hooks/useGameClock(game): a ClockSync updated on every game message (on_game_start, on_game_update, on_game_end), re-rendering every 250 ms while a clock runs and not at all once the game has ended.
  • components/GameClockView.tsx: one player's m:ss; highlighted with playerTurnColor while running; a warning style below CLOCK_LOW_TIME_MS.
  • GamePage: a clock beside each UserStatus in the board top toolbar, and in the mobile toolbar. The "opponent is offline" countdown banner and the OpponentOfflineCountdown string are removed (R6.2).
  • ReplayView: both clocks from getClockAtMove(record, shownMove), hidden when clockInitialMs is missing (R4.2, R4.3).
  • Texts: OpponentTimedOut / YouTimedOut become "Opponent ran out of time" / "You ran out of time" in English; the Turkish ones ("Rakibin süresi doldu", "Süren doldu") already fit.

Mobile app

GameScreen shows the two clocks with ClockSync and formatClock from core, next to the players' names, with the same running and low-time styles. Old app versions in the stores do not show the clock but are still bound by it; the update should be released before or with the backend (see TODO.md).

Testing

  • packages/core: getClockTimes, ClockSync (including a device clock that is off by minutes), getClockAtMove (with and without clock data, timeout at the end), formatClock.
  • Backend unit tests with the fake clock GameManager tests already use:
    • a move deducts the turn's time; an extra turn keeps the clock running; the other clock does not change;
    • a move after the time is gone is refused and ends the game with the opponent as winner;
    • endGamesOutOfTime ends only games at zero, tells players and spectators, and saves the game;
    • a completed game stops the clocks;
    • restoring version 2 snapshots does not charge downtime; version 1 snapshots get a new clock;
    • GameClockScheduler calls the sweep on its interval and stops (Jest fake timers).
  • Postgres repository: clock_initial_ms and remainingMs survive saving and reading.
  • End to end in Chrome with GAME_CLOCK_MS=20000: two guests play a few moves and the clocks agree on both pages; one stops moving and loses on time with the right texts on both sides; a reload keeps the clocks; a backend restart does not charge the time it was down; the replay shows the clocks.