Quick answer: Use the OS monotonic clock everywhere, never the wall clock. Estimate the client-server offset with a ping protocol that records send and receive timestamps, smooth the result with a median filter, apply corrections gradually, and continuously monitor drift to catch broken clients before they affect gameplay.
In a single-player game, time is whatever your engine says it is. In a multiplayer game, time is a contract between the server and every client, and that contract is constantly being violated by network jitter, clock drift, and players messing with their system clocks. Without solid clock synchronization, hit registration looks unfair, ability cooldowns desync, projectile prediction breaks, and lag compensation produces ghost hits. This guide describes the four primitives you need: a monotonic clock, an offset protocol, a smoothing filter, and a drift detector.
Use a Monotonic Clock Everywhere
The wall clock on a player’s machine is unreliable. NTP can adjust it backwards, daylight saving time can shift it by an hour, the user can set it to any value, and on some platforms it pauses while the device is asleep. None of this is acceptable for game timing.
Every platform exposes a monotonic clock that ticks forward at a steady rate, unaffected by system time changes. On Linux it is CLOCK_MONOTONIC; on Windows it is QueryPerformanceCounter; in C# it is Stopwatch.GetTimestamp(); in Go it is time.Now() with the monotonic component. Use these for all in-game timing — cooldowns, animations, network timestamps, anything that measures duration.
// Bad: wall clock can jump backwards
var startedAt = DateTime.UtcNow;
// ... later ...
var elapsed = DateTime.UtcNow - startedAt; // could be negative!
// Good: monotonic clock only moves forward
var startedAt = Stopwatch.GetTimestamp();
// ... later ...
var elapsedTicks = Stopwatch.GetTimestamp() - startedAt;
var elapsed = TimeSpan.FromTicks(elapsedTicks);
Reserve the wall clock for displaying dates and times to the user. Anything that affects gameplay logic must use the monotonic clock.
Estimate the Offset With a Ping Protocol
The server is the authority. Every client maintains an estimate of serverTime = clientMonotonicTime + offset. The offset is calculated by sending a ping and measuring the round trip:
// Client
t0 = clientMonotonic();
send({ type: "ping", t0: t0 });
// Server
on "ping": t1 = serverMonotonic();
send({ type: "pong", t0: msg.t0, t1: t1 });
// Client
on "pong": t2 = clientMonotonic();
rtt = t2 - msg.t0;
// Assume half of RTT was the outbound trip
estimatedServerTimeNow = msg.t1 + rtt / 2;
offset = estimatedServerTimeNow - t2;
The half-RTT assumption is wrong but useful. Network paths are rarely symmetric, but on average the assumption is close enough that errors cancel out across many samples. What you must not do is take a single sample and trust it. A random network spike can give you a 200 millisecond outlier that throws off all your timing.
Smooth and Apply the Offset Gradually
Collect samples continuously — once per second is reasonable for most games — and run them through a filter. The simplest robust filter is a sliding window median. Take the last 11 samples, sort them, and use the middle value. The median ignores spikes from packet jitter and gives a stable estimate.
Once you have a new offset estimate, do not snap to it. If the apparent server time on the client suddenly jumps forward by 30 milliseconds, every timer in the game will misbehave for one frame. Instead, apply the correction over many frames. If the new offset differs from the current one by D milliseconds, adjust by no more than one or two milliseconds per frame until you converge.
The exception is a large discrepancy — more than 500 milliseconds. That usually means the player’s connection just changed (Wi-Fi to cellular, VPN connect) and the previous offset is no longer relevant. In that case, snap to the new offset and log the event so you can investigate.
Detect and Alert on Drift
Even with good sync, things go wrong. A player’s laptop suspends and resumes; a server is migrated to a new host with a different clock source; an NTP server pushes a large adjustment. You want to know about these events before they cause gameplay issues.
Track two metrics per session: the variance of recent offset samples (high variance means the network is unstable or the clock is unreliable) and the rate of change of the smoothed offset (a steady drift means one of the clocks is running fast or slow). If either crosses a threshold, log a structured event with the session ID, the offset history, and the current network conditions.
On the server, aggregate these events across all sessions. A spike in drift events that all originated on the same physical server tells you the host’s clock is broken. A spike across all clients of one ISP tells you something changed at the network level. These signals are invaluable for diagnosing “the game feels laggy today” reports that have no obvious cause.
“A player reported that their abilities were going on cooldown a fraction of a second too soon. We pulled their clock-sync telemetry and saw their offset had drifted by 80 milliseconds over the course of an hour. Their laptop’s clock source was unstable. We added a thermal-throttling check and started using a different platform timer on machines with that hardware. Three other players with the same chip stopped reporting the issue.”
Related Issues
For more on multiplayer-specific bug reporting, see bug reporting for multiplayer games. For client-server desync debugging strategies, read best practices for error logging in game code.
Add a debug overlay that shows the current clock offset and recent jitter. The first time you see a player’s offset spike during a heated match, you will understand why hits feel inconsistent.