Skip to content

Commit 1dfd9db

Browse files
fix(web, game-server): implement monotonic local anchor for bulletproof timer sync" -m "Architecture updates:
- Migrated from ticking server countdowns to Absolute Time Deltas - Implemented hybrid requestAnimationFrame + performance.now() loop to defeat background tab throttling and OS clock drift - Added timeRemainingMs to all room sync and phase transition payloads for precise late-joiner synchronization - Purged stale roundStartTime state variables - Wrapped FSM round manager transitions in robust try/catch blocks to prevent cascaded server crashing
1 parent 772e31d commit 1dfd9db

10 files changed

Lines changed: 462 additions & 351 deletions

File tree

apps/game-server/src/fsm/roundManager.ts

Lines changed: 373 additions & 302 deletions
Large diffs are not rendered by default.

apps/game-server/src/rooms/Room.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export class Room {
6464
currentHint: '',
6565
wordChoices: null,
6666
correctGuessers: new Set(),
67-
roundStartTime: null, //now not used for the countdown we are using a synced hook
67+
phaseEndTime: null, //now not used for the countdown we are using a synced hook
6868
wordSelectionTimer: null,
6969
drawingTimer: null,
7070
intermissionTimer: null,
@@ -230,7 +230,7 @@ export class Room {
230230
this.state.currentHint = '';
231231
this.state.wordChoices = null;
232232
this.state.correctGuessers.clear();
233-
this.state.roundStartTime = null; //now not used for the countdown we are using a synced hook
233+
this.state.phaseEndTime = null;
234234

235235
// Reset all player scores and guess flags
236236
this.state.players.forEach((p) => {

apps/game-server/src/socket/handlers/messageHandlers.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,13 @@ export const handleChatMessage = (io: Server, socket: Socket) => (payload: unkno
120120
player.hasGuessedCorrectly = true; // Keep Player object in sync with correctGuessers Set
121121

122122
//Time-Decay Scoring Math (Early guesser gets more points)
123-
const timeElapsed = Date.now() - (state.roundStartTime || Date.now());
124-
const totalTime = state.config.drawTimeSeconds * 1000;
125-
// 1.0 is instant and 0.0 is the last second
126-
const timeRatio = Math.max(0, 1 - timeElapsed / totalTime);
123+
//Derive the elapsed time using absolute end time and the configured round duration(draw time).
124+
const phaseDurationMs = state.config.drawTimeSeconds * 1000;
125+
const derivedStartTime = state.phaseEndTime ? state.phaseEndTime - phaseDurationMs : Date.now();
126+
const timeElapsed = Date.now() - derivedStartTime;
127+
// 1.0 is instant and 0.0 is the last second (hard cap between 0 and 1 to avoid negative points if
128+
// the timer is off)
129+
const timeRatio = Math.min(1, Math.max(0, 1 - timeElapsed / phaseDurationMs));
127130

128131
//Base 100 points + up to 400 speed bonus points
129132
const pointsEarned = Math.floor(100 + 400 * timeRatio);

apps/web/src/components/Arena/ArenaHUD.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@ interface ArenaHUDProps {
4444
* @returns An animated `<span>` showing the remaining seconds.
4545
*/
4646
const HUDTimer = ({ duration }: { duration: number }) => {
47-
const { timeLeft } = useSyncedTimer(duration);
47+
const { localPhaseEndTime } = useGameStore();
48+
49+
const { timeLeft } = useSyncedTimer(localPhaseEndTime, duration);
4850
const isDangerTime = timeLeft <= 10;
4951

5052
return (

apps/web/src/components/Arena/ArenaOrchestrator.tsx

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -120,14 +120,31 @@ export const ArenaOrchestrator = () => {
120120
setRoomState(room);
121121
};
122122

123-
const handleRoomJoined = ({ room }: { room: SerializedRoom }) => {
123+
const handleRoomJoined = ({
124+
room,
125+
serverNow,
126+
}: {
127+
room: SerializedRoom;
128+
serverNow?: number;
129+
}) => {
124130
// Save active room code to localStorage for smart reconnection
125131
localStorage.setItem(ACTIVE_ROOM_KEY, room.roomCode);
132+
133+
//RECONNECT DRIFT CALCULATION
134+
let localPhaseEndTime = null;
135+
const phaseEndsAt = room.phaseEndTime;
136+
137+
if (phaseEndsAt) {
138+
const referenceTime = serverNow || Date.now();
139+
const timeRemainingMs = Math.max(0, phaseEndsAt - referenceTime);
140+
localPhaseEndTime = Date.now() + timeRemainingMs;
141+
}
126142
// Reset chat messages on join to avoid showing stale messages from previous sessions
127143
setRoomState({
128144
...room,
129145
chatMessages: [],
130146
totalRounds: room.config?.roundCount ?? 0,
147+
localPhaseEndTime,
131148
});
132149
};
133150

@@ -173,11 +190,13 @@ export const ArenaOrchestrator = () => {
173190
round,
174191
totalRounds,
175192
roundId,
193+
timeRemainingMs,
176194
}: {
177195
drawerId: string;
178196
round: number;
179197
totalRounds: number;
180198
roundId: number;
199+
timeRemainingMs: number;
181200
}) => {
182201
//Capture the current scores before the round starts so we can use
183202
//them for the "score delta" in the post-round overlay
@@ -201,6 +220,7 @@ export const ArenaOrchestrator = () => {
201220
gameState: GameState.ROUND_STARTING,
202221
players: resetPlayers, // Reset guessing status at the start of each round
203222
previousScores,
223+
localPhaseEndTime: Date.now() + timeRemainingMs,
204224
});
205225
};
206226

@@ -212,18 +232,18 @@ export const ArenaOrchestrator = () => {
212232
drawerId,
213233
wordLength,
214234
wordHint,
215-
roundStartTime,
235+
timeRemainingMs,
216236
}: {
217237
drawerId: string;
218238
wordLength: number;
219239
wordHint: string;
220-
roundStartTime: number;
240+
timeRemainingMs: number;
221241
}) => {
222242
setRoomState({
223243
currentDrawerId: drawerId,
224244
wordLength,
225245
currentHint: wordHint,
226-
roundStartTime,
246+
localPhaseEndTime: Date.now() + timeRemainingMs,
227247
gameState: GameState.DRAWING,
228248
wordChoices: [],
229249
});
@@ -234,11 +254,13 @@ export const ArenaOrchestrator = () => {
234254
reason,
235255
scores,
236256
isFinalRound,
257+
timeRemainingMs,
237258
}: {
238259
correctWord: string;
239260
reason: string;
240261
scores: Array<{ id: string; username: string; score: number }>;
241262
isFinalRound?: boolean;
263+
timeRemainingMs: number;
242264
}) => {
243265
const currentPlayers = useGameStore.getState().players;
244266

@@ -253,6 +275,7 @@ export const ArenaOrchestrator = () => {
253275
scores,
254276
players: updatedPlayers,
255277
isFinalRound: isFinalRound || false,
278+
localPhaseEndTime: Date.now() + timeRemainingMs,
256279
});
257280
};
258281

apps/web/src/components/ui/RoundEndOverlay.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,11 @@ const itemVariants: Variants = {
4848
* @returns {React.JSX.Element} The animated round-end overlay JSX.
4949
*/
5050
export const RoundEndOverlay = () => {
51-
const { correctWord, roundEndReason, players, previousScores, isFinalRound } = useGameStore();
51+
const { correctWord, roundEndReason, players, previousScores, isFinalRound, localPhaseEndTime } =
52+
useGameStore();
5253

5354
//Call the hook with current duration
54-
const { progress } = useSyncedTimer(GAME_CONSTANTS.ROUND_END_DISPLAY_SECONDS);
55+
const { progress } = useSyncedTimer(localPhaseEndTime, GAME_CONSTANTS.ROUND_END_DISPLAY_SECONDS);
5556

5657
// Sort players by who gained the most points this round
5758
const sortedPlayers = [...players]

apps/web/src/components/ui/WordSelectionOverlay.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* @description An animated overlay that prompts the drawer to choose a word
66
* while giving other players a waiting state and synced timer feedback.
77
*/
8-
8+
import { useGameStore } from '@/store/gameStore';
99
import { useSyncedTimer } from '@/hooks/useSyncedTimer';
1010
import { GAME_CONSTANTS } from '@scribblitz/shared';
1111
import { motion, Variants } from 'framer-motion';
@@ -59,8 +59,12 @@ export const WordSelectionOverlay = ({
5959
wordChoices,
6060
onSelect,
6161
}: WordSelectionOverlayProps) => {
62+
const { localPhaseEndTime } = useGameStore();
6263
// Calculate progress against the absolute server start time
63-
const { progress, timeLeft } = useSyncedTimer(GAME_CONSTANTS.WORD_SELECTION_TIMEOUT_SECONDS);
64+
const { progress } = useSyncedTimer(
65+
localPhaseEndTime,
66+
GAME_CONSTANTS.WORD_SELECTION_TIMEOUT_SECONDS,
67+
);
6468

6569
return (
6670
<div className="fixed inset-0 z-100 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md">
Lines changed: 37 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,71 @@
11
/**
2-
* useSyncedTimer Hook
3-
* Provides a high-precision countdown timer driven by `requestAnimationFrame`.
4-
* Computes an absolute end-time once and derives remaining seconds and a smooth
5-
* progress percentage (100 → 0) on every animation frame, avoiding drift that
6-
* `setInterval`-based timers are prone to.
2+
* Custom React hook for a synchronized countdown timer.
3+
*
4+
* The hook keeps the timer aligned to the server-provided end timestamp and
5+
* uses `requestAnimationFrame` plus `performance.now()` to avoid drift from
6+
* tab throttling or local clock changes.
77
*/
88

99
import { useState, useEffect, useRef } from 'react';
1010

1111
/**
12-
* Custom React hook that runs a frame-accurate countdown timer.
12+
* Tracks a countdown that stays in sync with a server-defined phase end time.
1313
*
14-
* On mount (or when `durationSeconds` changes) the hook calculates an absolute
15-
* end-time and uses `requestAnimationFrame` to update both a whole-second
16-
* `timeLeft` value (for display) and a smooth `progress` percentage (for
17-
* progress-bar animations). When the timer reaches zero, the optional
18-
* `onExpire` callback is invoked exactly once.
14+
* The hook derives the remaining time from `localPhaseEndTime`, updates the
15+
* display state on each animation frame, and calls `onExpire` once when the
16+
* countdown reaches zero.
1917
*
20-
* @param {number} durationSeconds - Total countdown length in seconds.
21-
* @param {() => void} [onExpire] - Optional callback fired when the timer reaches zero.
22-
* @returns {{ timeLeft: number, progress: number }} An object with `timeLeft` (whole seconds remaining) and `progress` (percentage 100 → 0).
18+
* @param localPhaseEndTime - Absolute end timestamp from the server, in milliseconds.
19+
* @param durationSeconds - Total countdown length in seconds.
20+
* @param onExpire - Optional callback invoked when the timer expires.
21+
* @returns An object containing `timeLeft` and `progress`.
2322
*/
24-
export const useSyncedTimer = (durationSeconds: number, onExpire?: () => void) => {
23+
export const useSyncedTimer = (
24+
localPhaseEndTime: number | null,
25+
durationSeconds: number,
26+
onExpire?: () => void,
27+
) => {
2528
const [timeLeft, setTimeLeft] = useState(durationSeconds);
2629
const [progress, setProgress] = useState(100);
2730

28-
// We use refs to hold values that don't need to trigger re-renders
29-
const endTimeRef = useRef<number>(0);
31+
// Store the animation frame id without triggering re-renders.
3032
const rafRef = useRef<number>(0);
3133

3234
useEffect(() => {
33-
// Use the provided server startTime, or fallback to Date.now()
34-
endTimeRef.current = Date.now() + durationSeconds * 1000;
35+
// Do not start ticking until the server provides an anchor.
36+
if (!localPhaseEndTime) return;
37+
38+
// Capture the remaining time at the moment this effect starts.
39+
const initialRemainingMs = Math.max(0, localPhaseEndTime - Date.now());
40+
41+
// Lock in a monotonic timestamp so local clock changes do not affect the countdown.
42+
const startTimeMono = performance.now();
3543

3644
const updateTimer = () => {
37-
const now = Date.now();
38-
const remainingMs = Math.max(0, endTimeRef.current - now);
45+
// Calculate elapsed time using the monotonic clock only.
46+
const elapsedMono = performance.now() - startTimeMono;
47+
const currentRemainingMs = Math.max(0, initialRemainingMs - elapsedMono);
3948

40-
// Calculate smooth percentage for the progress bar (100 down to 0)
41-
const newProgress = (remainingMs / (durationSeconds * 1000)) * 100;
49+
// Update the display state.
50+
const newProgress =
51+
durationSeconds > 0 ? (currentRemainingMs / (durationSeconds * 1000)) * 100 : 0;
4252
setProgress(newProgress);
53+
setTimeLeft(Math.ceil(currentRemainingMs / 1000));
4354

44-
// Calculate clean whole seconds for text display
45-
setTimeLeft(Math.ceil(remainingMs / 1000));
46-
47-
if (remainingMs > 0) {
55+
// Continue until the timer expires.
56+
if (currentRemainingMs > 0) {
4857
rafRef.current = requestAnimationFrame(updateTimer);
4958
} else {
5059
if (onExpire) onExpire();
5160
}
5261
};
5362

54-
// Kick off the loop
5563
rafRef.current = requestAnimationFrame(updateTimer);
5664

57-
// Cleanup loop on unmount
5865
return () => {
5966
if (rafRef.current) cancelAnimationFrame(rafRef.current);
6067
};
61-
}, [durationSeconds, onExpire]);
68+
}, [localPhaseEndTime, durationSeconds, onExpire]);
6269

6370
return { timeLeft, progress };
6471
};

apps/web/src/store/gameStore.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ interface GameStore {
4848
previousScores: Record<string, number>;
4949
chatMessages: ChatMessage[];
5050
drawTimeSeconds: number;
51-
roundStartTime: number | null;
51+
localPhaseEndTime: number | null;
5252
isGameAborted: boolean;
5353
abortReason: string | null;
5454

@@ -79,7 +79,7 @@ const initialState = {
7979
previousScores: {},
8080
chatMessages: [],
8181
drawTimeSeconds: 0,
82-
roundStartTime: null,
82+
localPhaseEndTime: null,
8383
isGameAborted: false,
8484
abortReason: null,
8585
};

packages/types/src/game.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ export interface RoomState {
8989
wordChoices: string[] | null;
9090
usedWords: string[]; //Tracks all words used in previous rounds to prevent repeats
9191
correctGuessers: Set<string>;
92-
roundStartTime: number | null;
92+
phaseEndTime: number | null;
9393
wordSelectionTimer: ReturnType<typeof setTimeout> | null;
9494
drawingTimer: ReturnType<typeof setTimeout> | null;
9595
intermissionTimer: ReturnType<typeof setTimeout> | null;
@@ -116,7 +116,7 @@ export interface SerializedRoom {
116116
revealedHintIndexes: number[];
117117
currentHint: string;
118118
correctGuessers: string[];
119-
roundStartTime: number | null;
119+
phaseEndTime: number | null;
120120
teamA?: string[];
121121
teamB?: string[];
122122
roundWinner?: 'team-a' | 'team-b' | null;

0 commit comments

Comments
 (0)