Home › Distributed systems › Other
algorithm low

Lag-compensated turn timer with a refilling quota

Problem Each participant has a time budget that runs while it is their turn, but the time a reply spends on the network is not their thinking time. The server must credit that delay fairly without letting anyone invent delay.

A server-side turn timer that credits back each move's measured network lag, capped per move and by a quota that refills a fixed amount per move.

O(1) time and O(1) state per participant per move
timeclocksanti-cheatlatencybudget
Overview

A per-turn time budget (Fischer increment: a fixed bonus added after each completed turn; Bronstein delay: the same, capped at the time spent) is only fair if network delay is not billed to the thinker. The server measures each reply's lag and refunds it, but out of a small quota that refills a fixed amount per move up to a ceiling. Honest jitter is refunded in full. A client that claims huge lag on every move can only recover the refill rate, so lag cannot be used as free time.

Key facts

Use when

  • Turn-based online games with clocks: board games, card games, word games.
  • Timed online exams or interviews where upload time of an answer must not count against the candidate.
  • Auctions and bidding windows with a per-bid latency allowance; order-entry or approval deadlines; remote operation with a network latency budget.

Avoid when

  • Real-time play where lag compensation rewinds world state instead of refunding time.
  • The server cannot measure transit time itself (no request/response timestamps on its side).

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

  1. Server stamps the turn start with its own monotonic clock when the previous move is accepted (or when the turn notice leaves the server).
  2. The participant answers; the server stamps arrival. server_elapsed = arrival - start.
  3. Lag estimate: elapsed - client_reported_think_time, or an RTT/ack estimate. It is a claim, so step 4 bounds it.
  4. credited = min(lag, per_move_cap, quota, elapsed).
  5. quota = min(quota + refill - credited, quota_max): honest small lags are repaid by the refill, sustained large "lag" drains it.
  6. charged = elapsed - credited; remaining -= charged.
  7. If remaining <= 0 the participant flagged: stop the clock, no increment. Otherwise add the increment (Fischer).
  8. 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_move is 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 i64 ms); never subtract Instants 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 remaining and 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_max and a hard timeout.
  • Charging the increment on a flagging move.
  • Mixing wall-clock timestamps from client and server.

Variants and alternatives

No compensationfine on LAN or when moves are slow relative to lag.
Fixed time handicap per move (delay clocks)simple, but it also removes time for honest fast players.
Estimate from past lag (decaying mean minus deviation)Lichess keeps such an estimate to display a lower-bound refund; the charge itself stays quota-based.
Rewind and re-simulate (lag compensation, client-side prediction)for real-time state, not budgets.

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

namelangrepoinstallverdictlicencehealthas_ofnotes
scalachess (LagTracker, Clock)Scalalichess-org/scalachessgit 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
lilaScalalichess-org/lilagit 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

Used by

Systems (1):

Alternatives and related