Time on every record
Every record carries at: the signer's own monotonic clock in milliseconds, counted from the moment it handled START. Every received record (except an ACK) is answered at once with an ACK whose at is when it was applied. That gives every turn a start and an end on the mover's own clock:
think(move) = at(move) − at(mover's ACK of the opponent's previous move)
A clock stops when its player signs a move and starts when that player acknowledges the opponent's move. Transit in between is charged to nobody, up to an allowance.
Both clock modes use think. Peer clocks (offline, hybrid) charge it directly. Anchored clocks (online) feed it to the lag quota as the player's claim.
Peer clocks: one function, no negotiation
Both devices, and the server when the game is reported, compute the clocks with the same function over the chain. Nothing is exchanged about time beyond the records, so the two screens always show the same clocks.
Reading the link from ACKs
Each ACK k of a record r is one transit sample, measured across two different clocks:
u(r) = at(k) − at(r) true transit + clock offset
m_wb = smallest u, white to black (last 16 samples)
m_bw = smallest u, black to white
rtt = m_wb + m_bw offsets cancel out
theta = (m_wb − m_bw) / 2 black clock minus white clock
J = max(2 × rtt, 30 ms) allowance per message
The offset between the two clocks is unknown and does not matter: it cancels in the round trip. A window of recent samples follows crystal drift.
Excess and the pause budget
A message that took longer than the fastest one in its direction plus J has excess: an outage, or a lie. Excess becomes paused time, taken from one budget per game shared by both players (WhitePawn: 2 s bullet, 5 s blitz, 10 s rapid, 20 s classical).
When an outage is longer than the budget left, the game is canceled unless both players sign RESUME, which turns the whole outage into paused time.
When the path changes: LINK
The floors m_wb and m_bw are the fastest transit in the window, so they belong to the fastest path the records took. When that path goes away (two phones lose their Bluetooth link and go on over the server), every message on the slower path shows the difference as excess until the fast samples have left the window. A 20 ms path replaced by a 200 ms one, with J at 80 ms, costs about 100 ms per message for 16 messages in each direction: more than a bullet game's whole pause budget.
So either player signs a LINK record (op 234) when its own links show the change (see Links). It carries nothing: which path a record took cannot be proven, and the clock function never asks. At LINK's point in the order:
- The windows are emptied. No sample from before LINK counts for the floors or
rttagain. - A settle opens.
Jandthetakeep their values from before LINK (the clock offset does not change with the path). Every new sample is pending: no excess is charged yet. - The settle closes. That happens once both directions have
link_settlesamples since LINK (WhitePawn: 4). The floors,rtt,thetaandJcome from those samples, and the excess of every pending sample, against the new floors, is taken from the pause budget. A game that ends with a settle open settles at its end.
A LINK while a settle is open is void, so both players noticing the change at once costs nothing, and a stream of LINKs cannot keep a settle open. LINK's own ACK is the first sample of the new window.
The clocks
remaining(X) = initial + increment × moves(X) − Σ think(X's moves)
The first move of each side is free, as on every chess server. A move whose think takes its signer below zero counts as FLAGGED. A FLAG_CLAIM is valid when, converted to the flagged player's clock with theta, its time lies more than remaining + J after that player's turn began. An honest client signs FLAGGED itself when its own clock runs out.
Peer clocks: live checks
The chain alone cannot tell a slow link from a lie. The devices can, while the game runs: each knows its own clock is true. They ping each other every 250 ms on every link that carries the records, inside that link's session (unsigned, never stored), and measure round trip, offset and clock rate NTP-style.
With more than one link, each link has its own pings and round trip, and a check only pairs a record and a ping that went over the same link session: order holds within one link, never across two. Copies that arrive in a session's flush (before its first PING) or as the answer to a resend request are late by design: the early-send check skips them, and their excess still counts. See Links.
| Check | Fails when | Catches |
|---|---|---|
| late ACK | an ACK claims a receipt later than a pong that proves the record had already arrived | a receiver stealing thinking time |
| early send | a record claims an at earlier than a ping the sender sent after that moment | a sender stopping its clock early while still pinging |
| future | a record claims an at later than its arrival allows | a flag claim dated in the future |
| clock rate | the opponent's clock runs more than 0.1 % fast or slow | a slowed clock that hides think time |
| asymmetry | the chain's round trip exceeds the ping round trip rtt_ping by more than max(2 × rtt_ping, 30 ms) | a steady lie that the chain alone reads as a slow link |
For the asymmetry check a device notes, with every sample of the window, the best ping round trip it had at that moment (on its fastest open link). rtt_ping is the mean of the two directions' smallest noted values. The allowance comes from the pings, not from J: J grows with the chain's round trip, so a check against J could never fail. And using the pings of the same samples means a faster link coming up does not look like a lie.
A failed check makes the device sign DISPUTE (with the reason and the offending record) and stop. The game ends disputed: no result, unrated. A device that simply stops pinging is an outage, nothing more.
Peer clocks: heartbeats
A player whose clock runs out on its own turn is flagged by the opponent's FLAG_CLAIM. Without more, it could stay silent, let the claim be signed, and report a move later, dated before its deadline: a concurrent move goes first in the order, so the claim would be void. Pings cannot stop that: they are neither signed nor kept.
So while a game with peer clocks runs, each device sends a signed heartbeat at least once a second, as the last field of a PING, on every open link: "at my clock at, my newest record was head_seq". The opponent keeps the latest one and puts it in its anchors and its report (layout on Byte layouts).
A record contradicts its signer's heartbeat when it has a higher seq than head_seq and an at before the heartbeat's, or a seq up to head_seq and an at after it. That counts as a fork: the signer loses. An honest client signs heartbeats and records under one lock from one clock, and never contradicts itself.
Once the opponent holds a heartbeat from after the deadline, the flag stands. What is left is cutting every link while the own clock still runs, then reporting a move dated after the last heartbeat that got through. That move was made during an outage: the game is canceled, like any hop-off, never won.
What a peer-clock cheater can still gain
| Trick | Most it gains without a DISPUTE |
|---|---|
| date a move earlier than it was sent | J per move (tens of ms on a local link) |
| acknowledge later than received | J per move |
| run the clock slow | 0.1 % of its thinking time |
| fake an outage | the pause budget, once per game |
| sign LINK at will | nothing more: one settle at a time, measured against its smallest samples |
| stay silent past its flag, then report a move dated in time | nothing: its own heartbeat from after the deadline contradicts the move, a fork |
| anything bigger | nothing: DISPUTE or canceled, never a win |
Anchored clocks (online)
With a full arbiter, both clients anchor every record they sign, right after sending it, and every record they receive. The server times each move from existence times:
interval = E(move) − E(opponent's previous move)
charge = interval − refund(interval − think)
The refund comes from a lag quota, a port of lichess' LagTracker. A client that lies about its think time gains at most the quota; an honest client on a bad connection is not flagged by the network.
est = initial + 40 × increment seconds
gain = min(1000 ms, est × 4 ms + 150 ms) earned per move
quota = 3 × gain at the start, at most 7 × gain
refund = min(max(lag, 0), quota)
quota = min(quota + gain − refund, 7 × gain)
Flags and abandonment are the server's decisions, signed as verdicts.