Deep dive
How it works
In: server_elapsed (server-measured time the turn took), measured_lag (estimated network share of it), the clock's config · Out: time charged, whether the participant flagged (ran out of time) · State: remaining time, quota, per-participant config (increment, refill, ceiling, per-move cap) · Cost: O(1) arithmetic, no allocation
- Server stamps the turn start with its own monotonic clock when the previous move is accepted (or when the turn notice leaves the server).
- The participant answers; the server stamps arrival.
server_elapsed = arrival - start. - Lag estimate:
elapsed - client_reported_think_time, or an RTT/ack estimate. It is a claim, so step 4 bounds it. credited = min(lag, per_move_cap, quota, elapsed).quota = min(quota + refill - credited, quota_max): honest small lags are repaid by the refill, sustained large "lag" drains it.charged = elapsed - credited;remaining -= charged.- If
remaining <= 0the participant flagged: stop the clock, no increment. Otherwise add the increment (Fischer). - While a turn is pending, the server flags only when
remaining <= pending - min(quota, grace_max): the grace is again the quota.
Edge cases: the first move often has no history, so start the quota above one refill (Lichess uses 3x its gain). Reconnects restart the stamp at the server's resume time, not the client's. Premoves arrive with near-zero think time and lag-like gaps: compute lag only from the server's own stamps. Never mix wall-clock and monotonic time; clock drift between machines is irrelevant because only durations measured on one machine are used (see clock synchronization).
Code
/// Server-side turn clock with Fischer increment and a lag quota. All times in integer ms.
pub struct TurnClock {
pub remaining: i64,
increment: i64,
quota: i64, // lag credit currently available
quota_gain: i64, // refill per move
quota_max: i64, // ceiling of the quota
per_move_cap: i64,
grace_max: i64, // most grace ever given at the flag check
}
#[derive(Debug, PartialEq)]
pub struct Charge { pub charged: i64, pub credited: i64, pub flagged: bool }
impl TurnClock {
pub fn new(initial: i64, increment: i64, quota_gain: i64, quota_max: i64, per_move_cap: i64) -> Self {
TurnClock {
remaining: initial, increment, quota: (quota_gain * 3).min(quota_max),
quota_gain, quota_max, per_move_cap, grace_max: 2 * quota_gain,
}
}
pub fn quota(&self) -> i64 { self.quota }
/// `server_elapsed`: server's own monotonic time from "move request sent" to "move received".
/// `measured_lag`: the part of it the server attributes to the network (never trusted blindly).
pub fn on_move(&mut self, server_elapsed: i64, measured_lag: i64) -> Charge {
let elapsed = server_elapsed.max(0);
// Credit = lag, but never more than the per-move cap, the quota left, or the elapsed time itself.
let credited = measured_lag.max(0).min(self.per_move_cap).min(self.quota).min(elapsed);
self.quota = (self.quota + self.quota_gain - credited).min(self.quota_max);
let charged = elapsed - credited;
self.remaining -= charged;
let flagged = self.remaining <= 0;
if !flagged { self.remaining += self.increment; } // no increment on the move that flags
Charge { charged, credited, flagged }
}
/// Timer fired while the player is still thinking: flag only after a grace of the quota left.
pub fn flag_due(&self, pending: i64) -> bool {
self.remaining <= pending - self.quota.min(self.grace_max)
}
}
Compiled and run (stable Rust, release) with asserts: 30 ms of honest lag is credited in full; a client claiming 5 s of lag on 100 consecutive moves gets 120 ms on the first (the initial quota), then 40 ms per move, total ≤ 120 + 100·40, never above the 200 ms per-move cap; the quota stays within 0..=280; the flag fires when remaining <= 0 and the flagging move gets no increment.
Complexity and constants
O(1) time per move, about 56 bytes of state per participant with seven i64 fields (add a u8 flag and padding in practice). No allocation, no branches that depend on data except the min chain and the flag test. Measured here: 10M calls of on_move took order of 4 ns each (3.7-4.7 ns over three runs, Apple M4 Pro, one thread). Per-move network cost is orders of magnitude larger: a clock broadcast is a packet (tens of bytes payload), a timestamp is a clock_gettime (order of tens of ns).
Constants that matter: refill (scaled to the time control; Lichess: order of hundreds of ms for minute-scale games, see Implementations), ceiling (a few refills), per-move cap, and grace. Lichess derives its refill from the time control: see Implementations.
Efficiency
- Cost: one
on_moveis about 4 ns (measured, M4 Pro, 10M calls); bound is network round trip and timestamp reads, not arithmetic. Memory is under 100 bytes per participant. - Levers:
- Monotonic clocks, integer ms (or cs) — no float rounding drift, exact asserts on budgets — always.
- Stamp at the network edge (the socket read, not after queueing) — removes server-side queueing from the player's bill — when the server has a queue or worker pool.
- No allocation per move; fixed-size struct — avoids allocator and cache misses — always.
- Batch clock broadcasts (one message per turn change, clients tick locally) — fewer packets than 1 Hz ticks — many spectators.
- Per-move cap in addition to the quota — bounds a single claim and simplifies audits — untrusted clients.
- Naive → standard → state of the art:
- Charge raw elapsed time, ignore lag · O(1) but punishes slow links.
- Refund client-reported lag up to a fixed cap per move · O(1), exploitable by claiming the cap every move.
- Refill-based quota plus grace at the flag check, refill scaled to the time control · O(1), credit bounded by initial + n·refill (Lichess).
Implementation notes
- Keep the quota per participant and per game, not per connection; a reconnect must not refill it.
- Store a Duration type (or a newtype over
i64ms); never subtractInstants that could underflow, saturate at 0. - Clamp every client number on receipt (negative, absurd, NaN-like values) before it reaches
min. - Use the same arithmetic for the pending-turn timer and for the stored move so a display never disagrees with the verdict.
- Spectators receive
remainingand the turn start; the participant's client may additionally display the lag it expects to be refunded.
Pitfalls
- Trusting the client's think time: a client under-reports it to inflate "lag". The quota bounds the gain, nothing else does.
- Letting the quota grow with time instead of per move: an idle participant stockpiles credit.
- No cap on grace: a stuck connection holds the turn open indefinitely. Always bound by
grace_maxand a hard timeout. - Charging the increment on a flagging move.
- Mixing wall-clock timestamps from client and server.
Variants and alternatives
In production
- Lichess (online board games): lag tracker per player, tuned by time control; the clock is server-authoritative.
- Pattern, not code, in other fields: timed online exams crediting upload time, auctions allowing for bid latency, order entry or approval windows with a network allowance, remote operation under a latency budget. Their parameters are not public in the sources checked here.
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| scalachess (LagTracker, Clock) | Scala | lichess-org/scalachess | git clone https://github.com/lichess-org/scalachess | — | — | — | — | Used by Lichess. LagTracker.scala: comp = lag.atMost(quota), quota = (quota + gain - comp).atMost(max); initial quota = 3 gain, max = 7 gain; gain = min(100, estimateTotalSeconds·2/5 + 15) cs, estimate = limit + 40·increment; flag grace = min(quota, 200 cs) in Clock.outOfTime |
| lila | Scala | lichess-org/lila | git clone https://github.com/lichess-org/lila | — | — | — | — | The Lichess server; depends on scalachess (not read in detail here) |
Sources
- Lichess scalachess,
LagTracker.scala,Clock.scala,MoveMetrics.scala(read 2026-10-21). LagTracker · Clock · MoveMetrics - Lichess issue titled "Hyperbullet lag quota is high"; referenced from the quota-gain comment in
LagTracker.scala. issue 12097 - Wikipedia, Time control (Fischer increment and Bronstein delay). article