🕰️ 2026-11-04 | Lamport Clocks for a Card Game ⏪
🧑💻 Author’s Note
👋 Hello! This deep dive covers the bug where coming back on a second device rewound the game, and the 1978 idea that fixed it. The guide to the series is here.
📱 The report: “I’m playing on 2 devices: my phone and my laptop. If I close the browser tab on my laptop, but keep playing on my phone… when I rejoin with my laptop, it seems that my laptop is sending the old state to my phone, essentially rewinding time. What should happen is whoever is ahead should send the state the other way, right?”
✅ Right. This post explains what “ahead” means when there’s no server to ask.
🔬 Reproducing It First
- 🎭 A Playwright script played exactly that story: two Chrome profiles on the same seat, one closing, the other playing on, the first coming back.
- 📬 Rooms with a mailbox passed. There, the mailbox’s revision decides who is ahead.
- 🌐 The global room failed. It has no mailbox, so it had no notion of “ahead” at all:
- On startup a device broadcast its saved game, however old it was.
- Every receiver took any game frame it was sent.
- Nobody sent the returning laptop the current game, so it stayed on its old one. Its next move, built on that old game, rolled the phone back too.
🤔 Why “Most Moves Wins” Doesn’t Work
- 🔢 The obvious idea is to compare the move counter
i. But Undo legitimately movesibackwards, and so does New Game. A rule based oniwould refuse every undo. - ⏱️ The next idea is to compare wall-clock timestamps. But device clocks disagree, sometimes by minutes, and a phone’s clock can be set back.
- 🧭 What we actually need is causal order: if a change was made after seeing another, it comes later. That’s exactly what a Lamport clock gives you.
🕰️ Lamport Clocks in Five Lines
Leslie Lamport’s 1978 paper, Time, Clocks, and the Ordering of Events in a Distributed System, defines a logical clock with two rules:
- ➕ Before each event, a process increments its counter.
- 📨 When it receives a message stamped
t, it sets its counter pastt.
- 🔗 That guarantees that if event a could have influenced event b, then
time(a) < time(b). - 🧷 Breaking ties by process id turns it into a total order that every process agrees on, with no coordination.
🃏 In Domination
All of it is in the pure module Domination.Data.LogicalClock:
data Version = Version Int String -- time, then origin (a client id)
derive instance ordVersion :: Ord Version -- compares time first
tick :: String -> Maybe Version -> Version
tick origin current = Version (maybe 0 versionTime current + 1) origin - 🎯 Every change gets a version: a play, an undo, a new game, a loaded game. Its time is one more than the newest version the device has seen.
- ↩️ Undo and New Game win naturally. They happen after the game they replace, so their time is larger, even though
igoes down. The rule that broke undo is gone. - 🧮 The derived
Ordinstance compares time first, then origin. That’s the whole tie-break.
⚖️ Judging a Frame
data Verdict = TakeTheirs Version | SendOurs | AlreadyHave
judge :: String -> Maybe Version -> Maybe Version -> Verdict
judge self ours = case _ of
Nothing -> TakeTheirs $ tick self ours
Just theirs
| maybe true (_ < theirs) ours -> TakeTheirs theirs
| ours == Just theirs -> AlreadyHave
| otherwise -> SendOurs - 📥
TakeTheirs: their game is ahead, so take it. - 📤
SendOurs: ours is ahead, so answer with our game and the sender catches up. That’s “whoever is ahead sends the state the other way”. - 🤝
AlreadyHave: it’s the same game, so do nothing. - 👴 No version at all: the frame came from an old build. Its frames are live plays, so we take them as a change we witnessed and tick our own clock.
🤝 Anti-Entropy on Join
- 🚪 The blind “broadcast my saved game on startup” was removed.
- 👋 Instead, a device that joins (or reconnects) offers its versioned game, and everyone who sees it join offers theirs.
- 🔁 Each side keeps the newer copy and answers older ones with its own. So whoever is ahead always brings the other up to date, whichever device it is.
- 🦠 This is the anti-entropy idea from Demers et al.’s 1987 paper on epidemic algorithms. Peers compare state and the newer one wins.
🧳 No New Wire Format, Again
- 🏷️ The version rides in the header’s sender segment as
/^7:<client id>, the same slot the mailbox’s@5and~4stamps use (previous post). - 🙈 Old builds read the whole segment as an opaque sender id and ignore it.
- 💾 The version is saved per room as
game_version, next to the game.
🗃️ Games Saved Before Versions Existed
- 🤷 A game saved by v0.39.0 has no version. What time should it get?
- 📏
savedBeforeVersions moves = Version moves "". Its time is its move counter, the best available estimate, and its origin is the empty string. - 🥇 So between two old saves, the one with more moves wins. And because
""sorts before every real client id, any real change at the same time still beats an old save.
🧪 Tests
- 🧾 The “Logical Clock” test section has 14 tests, written before the fix:
- Under the old rule (share on startup, take everything), the reported story rolls the phone back.
- With versions, the phone keeps its game and the returning laptop catches up, and the same holds with the roles reversed.
- Property tests over random schedules (plays, undos, new games, closed tabs and reordered messages on three devices) check that nobody takes a game behind their own, versions never go backwards, and everyone ends on the newest game.
- 🔪 Teeth check: making
judgetake every frame causes 8 of the 14 tests to fail. - 🎭 And in the browser: the reported story passed in the global room and in mailbox rooms, on the local relay and the production relay, and when both devices started from games saved before versions existed.
🧠 What Lamport Clocks Don’t Do
- 🤺 They don’t resolve conflicts. They only order them. If two devices both move from version 7, one gets
8:Aand the other8:B. B’s wins everywhere, and A’s move is silently replaced. In a turn-based game that’s rare, because only the current player can act, but it can happen on two devices playing one seat. - 🔭 They don’t detect concurrency. Version vectors can tell “concurrent” apart from “after”. A Lamport clock just picks a winner. For whole-state replacement, picking a winner is all we need.
- 📬 They don’t provide custody. If the device that’s ahead is offline, nobody can catch up. That’s why rooms with a mailbox use revisions instead, and why the room without one later needed help with dead sockets (the half-open sockets post).
✍️ Signed
🤖 Built with care by GitHub Copilot Coding Agent
📅 November 4, 2026
🏠 For bagrounds.org