Four frames from the Camelot Nights trailer: the team vote, the 3–2 tally, quest cards turning, the fail. The trailer restages the table as a scripted animation, so its pacing is not the game’s; the timelines below are.

← Notes

Why Camelot Nights times its reveals on the server

Games5 min read

A vote, a tally, cards turning one by one: how the game server schedules each reveal so every player sees the same beat at the same moment.

Camelot Nights is an online social deduction game for 5–10 players. At two moments the whole table waits on something hidden: everyone votes on the proposed quest team, and then the team plays secret success or fail cards. The game doesn’t just print the outcome. It plays it out: tokens turn over one at a time, the cards are shuffled and flipped, the result lands.

Those reveals are timed by the game server, not by each browser. This note follows one reveal through the code, and the clock problem that makes the difference.

Four beats, in milliseconds

Every beat length lives in one object, TIMING, in the engine package that both the server and the web client import (packages/engine/src/timeline.ts). Its comment says the client animations are laid out to fit these numbers exactly.

A quest reveal has four phases:

  1. Gather, 900 ms: the played cards slide to the centre of the table.
  2. Shuffle, 1,300 ms: they are stacked and mixed.
  3. Flip, 750 ms a card: they turn over one by one. After a fail, the next beat waits another 250 ms, so the fail lands heavier.
  4. Result, 1,700 ms: the success or fail stamp, plus 900 ms if this quest decides the game (a third success or a third fail).

questDuration() adds them up. A three-card quest with one fail, like the frames above, takes 900 + 1,300 + 3 × 750 + 250 + 1,700 = 6,400 ms.

The vote reveal before it is built the same way (voteDuration()): a 1,700 ms intro with a 3 · 2 · 1 count over the face-down tokens, one token every 380 ms in seat order, then 1,600 ms for the tally. Before the last token there is always a 300 ms pause, whatever it shows; the comment on voteLastPause gives the reason, “so the pause never hints at the result”.

Vote reveal · 5 players5,500 ms

  1. Intro0–1,700 ms3 · 2 · 1 over the face-down tokens
  2. Tokens1,700–3,900 msone every 380 ms in seat order, a fixed 300 ms before the last
  3. Tally3,900–5,500 msapproved or rejected

Quest reveal · 3 cards, 1 fail6,400 ms

  1. Gather0–900 msthe played cards slide to the centre
  2. Shuffle900–2,200 msstacked and mixed
  3. Flip2,200–4,700 ms750 ms a card, +250 ms after a fail
  4. Result4,700–6,400 msthe stamp; +900 ms if it decides the game
Both reveals to the same scale, from the beat lengths in the game’s engine (TIMING in packages/engine/src/timeline.ts). Marks on the bars: each token or card turning; the copper one is the fail.

The server writes the schedule

Each room is a Cloudflare Durable Object (apps/realtime/src/room.ts). When an action changes the game, applyFor() calls queueCinematics(before, after), which turns the new event into a list of cinematics. Each one has a startedAt in server milliseconds and a duration, placed back to back: a quest reveal and, if that quest ended the game, the finale right after it. There are six kinds: proposal, vote, quest, Lady of the Lake, assassination and finale.

Holding that list on the server settles several things at once:

  • The old state stays on screen. The room keeps the pre-event game state in prev, and view() sends it instead of the new one until the first cinematic has ended. The quest board doesn’t fill in before the last card has turned.
  • Nobody acts mid-reveal. A game:action is rejected with revealing while any cinematic is still running, and bots are scheduled from gateEnd(), the end of the last one.
  • The switch is on time. touch() sets the room’s alarm for the moment the first cinematic ends; the alarm drops it and pushes the real state to every socket.
  • The cards come in one order. The server rebuilds the quest cards from the fail count alone and shuffles them with crypto.getRandomValues. Every client gets that single order, and it holds no link to who played which card.
  • The game ends after the finale. The room reports finished only once the finale has played out.

One sync problem: whose clock?

A timestamp from the server is only half of it. Each browser still has to turn “this reveal started at T” into “this is how far in we are now”. The obvious way, Date.now() − startedAt, uses the device’s own clock, and nothing makes a laptop’s or a phone’s clock agree with the server’s.

Take the quest reveal above, one second after it starts by the server’s clock, and two players whose device clocks are off:

One second into the quest reveal
Whose clockBy its own clockWith the skew correction
Server clock is the reference1,000 ms Shuffle1,000 ms Shuffle
Player A clock 4 s ahead5,000 ms Result1,000 ms Shuffle
Player B clock 2 s behind−1,000 ms not started1,000 ms Shuffle
An illustration with example clock errors, not a measurement. Phases from the reveal above: Gather 0–900 ms, Shuffle to 2,200, Flip to 4,700, Result to 6,400.

By the server’s clock the cards are being shuffled. Trusting their own clocks, player A already sees the result stamp and player B sees nothing yet: three clocks, three different moments.

The web client corrects for this in useTimeline() (apps/web/src/lib/timeline.ts). Every room:state message carries serverNow, the server’s clock when the view was built. The client keeps skew = serverNow − Date.now() and from then on reads server time as Date.now() + skew. The active cinematic is the one whose window contains that time, and elapsed counts from its startedAt. Both players are then one second in, watching the shuffle with everyone else.

Each stage component (QuestStage, VoteStage and the others) lays out its beats once, from the elapsed time when it mounts. beats() turns each beat into an animation delay, or marks it as already past so it renders in its end state, and useSchedule() skips sounds whose moment has gone. A player who refreshes or reconnects halfway through a reveal joins it at the right offset instead of replaying it from the top.

The correction has a limit. serverNow is stamped when the message is built, so by the time a client reads it, it is already as old as the trip took, and the client doesn’t measure round trips. Each player runs behind the server by however long their last state message took to arrive: an error the size of the network delay, instead of the size of their clock error. The client also checks which cinematic is active every 100 ms; the animations inside it run on the delays worked out at mount.

What it replaced

Server timing came in with commit f0bc526 (“Server-timed cinematics, night sequence, …”). Before it, Cinematics.tsx in the web app started each overlay when its own browser noticed a new event, kept a local “busy” clock (lib/cinematic.ts) and dropped a vote flash if something else was still playing on that screen. The server sent the new state straight away, and a bot waited a fixed 5.5 s after a quest result before it moved. Each of those choices was made on each device separately, from its own clock and the moment the message happened to arrive.

What it doesn’t do

This is about pacing, not secrecy. The cinematic in each state message already carries the votes, or the shuffled cards and the result, so anyone reading the WebSocket traffic knows the outcome as the reveal begins. What stays hidden is who played which card, and everyone’s role until the game is over.

The multi-client scenario test (apps/realtime/test/scenarios.mjs) checks the two properties this note is about: every client receives an identical cinematic timeline, with the same ids and the same startedAt, and while a reveal runs its outcome never shows in the displayed state. It also reconnects a player on a fresh socket in the middle of the assassination reveal and checks that they get the same beat back: the same ids and the same start times.