# 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`) ```ts // 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`) ```ts class GameClockScheduler { constructor(gameManager: GameManager, intervalMs = CLOCK_CHECK_INTERVAL_MS /* 1000 */) start(): void // setInterval → gameManager.endGamesOutOfTime(), which reads GameManager's own clock stop(): void } ``` `src/index.ts` starts it after `restoreLiveGames()`, 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.