One authoritative clock, two obedient players
Synchronisation is the product. This page specifies exactly what the server stores, how often clients speak, how offset is computed, and what a client is allowed to do about a gap.
The correction loop, running
The guest above periodically stalls, falls behind, and closes the gap by playing marginally faster. No seek occurs, no audio artefact is audible, and the status line never needs to apologise. That is the behaviour the rest of this page defines.
What the server considers true
A room's playback state is stored as an anchor plus a rate, never as a continuously updated position. Position is always derived, which removes an entire class of write-contention bugs.
function authoritativePosition(room, nowServerMs) {
if (room.state !== 'playing') return room.anchorPositionMs;
const elapsed = nowServerMs - room.anchorServerMs;
return room.anchorPositionMs + elapsed * room.rate;
}
// every transition rewrites the anchor atomically
function applyPause(room, nowServerMs) {
room.anchorPositionMs = authoritativePosition(room, nowServerMs);
room.anchorServerMs = nowServerMs;
room.state = 'paused';
room.seq += 1;
}Measuring the gap between two clocks
Before a client can judge its own drift, it needs to know how far its clock sits from the server's. Partmov uses the NTP-style four-timestamp exchange, filtered for jitter.
client → { type: "sync_ping", t0: <client monotonic ms> }
server → { type: "sync_pong", t0, t1: <server recv>, t2: <server send>,
state, anchorPositionMs, anchorServerMs, rate, seq }
client: t3 = now()
rtt = (t3 - t0) - (t2 - t1)
offset = ((t1 - t0) + (t2 - t3)) / 2 // add offset to client clock → server clock
// keep the 5 lowest-RTT samples from the last 30 s; use their median offset.
// discard any sample whose rtt exceeds 2.5x the running median — that is queueing delay,
// and asymmetric delay is what poisons naive offset math.- Cadence. Ten pings over the first 3 seconds after joining to converge quickly, then one ping every 5 seconds in steady state. That is 12 messages a minute per client, which is free.
- Heartbeat. Separately, every
1 sthe client emits adrift_reportwith its local position, buffered-ahead milliseconds, current rung, dropped frames, and readyState. The server replies with the authoritative position so the client can correct even if it missed a broadcast. - Why both. Ping measures clock offset, which changes slowly. Heartbeat measures media drift, which changes constantly. Conflating them makes the controller chase network noise.
A ladder of increasingly visible responses
The controller always picks the least visible action that can close the gap, and it hysteresis-locks so it never oscillates between two remedies.
| Measured drift | Response | Client behaviour |
|---|---|---|
| |drift| ≤ 40 ms | Locked | Do nothing. Report the value for metrics only. |
| 40 ms – 250 ms | Fine nudge | Set rate to 1.00 ± up to 0.02 until the gap closes, then release to 1.0. |
| 250 ms – 1.5 s | Coarse nudge | Set rate to 1.00 ± up to 0.05, which stays below the pitch-shift and lip-sync perception threshold for speech. |
| 1.5 s – 10 s | Silent seek | Seek to the authoritative position, keep playing. Prefer executing at the next segment boundary so the buffer is not discarded. |
| > 10 s or after an explicit seek | Re-arm | Pause locally, seek, refill the buffer, report ready, and rejoin the start-together handshake. |
const LOCK = 40, FINE = 250, COARSE = 1500, REARM = 10_000;
function correct(video, driftMs /* local - authoritative */) {
const gap = Math.abs(driftMs);
if (gap <= LOCK) { setRate(video, 1); return 'locked'; }
if (gap <= COARSE) {
const span = gap <= FINE ? 0.02 : 0.05;
// close the gap over ~4 s rather than instantly: gentler and self-damping
const nudge = Math.min(span, gap / 4000);
setRate(video, driftMs < 0 ? 1 + nudge : 1 - nudge);
return 'nudging';
}
if (gap <= REARM) { seekAtSegmentBoundary(video, authoritative()); return 'seeking'; }
return rearm(video); // pause, seek, refill, report ready, rejoin start handshake
}
// setRate never touches room state: the room's rate stays 1.0 and only this
// device's decoder runs marginally fast or slow. Audio uses the browser's
// pitch-preserving resampler, so ±5% is inaudible on dialogue.Why rate nudging rather than seeking. A seek discards the decode pipeline and often the buffer, which costs 200–800 ms of black frames and frequently causes the very rebuffer it was meant to fix. A 3 percent rate change closes a 300 ms gap in 10 seconds with no visual or audible artefact at all.
Start together, pause for both, seek once
Every state change is scheduled rather than immediate, which is what turns two independent players into one shared timeline.
host → play_requested { seq }
server : verify role, room state, and that every participant reported ready
server : startAt = now() + max(400 ms, 2 x worst_observed_rtt/2)
server → playback_started { anchorPositionMs, anchorServerMs: startAt, rate: 1, seq }
client : targetLocal = startAt - clockOffset // convert to local time
client : video.currentTime = anchorPositionMs / 1000
client : wait until performance.now() >= targetLocal - 20 ms, then play()
client : if the deadline is already past, play() immediately and let the
drift controller absorb the remainder — never delay the film to be tidypause_requested : allowed for host and guest (this is 'pause for both').
Server anchors the position, broadcasts playback_paused,
and the room shows who paused it.
seek_requested : host only, unless room.shared_control = true.
Server clamps the target to [0, duration], bumps seq,
sets state = 'armed', and broadcasts seek_committed.
Both clients seek, refill, report ready; the server then
re-runs the start-together handshake automatically if the
room was playing before the seek.
rate_changed : host only. Applies to room.rate; the drift controller's own
nudges are layered on top of it per device.
track_changed : subtitle and audio selection are room state, so both clients
switch at once. Subtitle changes never re-arm playback;
audio track changes do, because they reset the media element.Disconnects, packet loss, and stale commands
- Missed broadcasts are self-healing. Because every heartbeat response carries the full canonical state and a
seq, a client that missed aplayback_pausedframe discovers it within one second and reconciles. - Stale commands are dropped. Commands carry the
seqthe client believed was current. If it is lower than the room’s, the server rejects it withcommand_rejected { reason: "stale", currentState }instead of applying an out-of-order intent. - Reconnection is exponential and jittered. 0.5 s, 1 s, 2 s, 4 s, capped at 10 s with ±20 percent jitter. The socket reattaches with the same session and room token, receives
room_joinedwith full state, and re-arms if it fell outside the seek threshold. - Grace period, not eviction.A dropped participant stays in the room for 90 seconds and the status line shows “reconnecting”. The film keeps playing for the person still present unless courtesy pause is enabled, which is the default for a two-person room.
- Liveness. Server-initiated WebSocket ping every 20 seconds; two missed pongs mark the participant offline. Absent heartbeats for 5 seconds while a room is playing raise a
participant_status_changedevent so the other viewer is told something is wrong. - Server restart. On boot the sync service loads room checkpoints, treats every room as
pausedat its last known anchor, and lets clients re-arm. Restarting mid-film costs a two-second pause, never a lost position.
Low latency without gambling on the network
Startup delay and rebuffering are the two ways sync fails in practice, so the buffer policy is part of the sync design rather than an afterthought.
| Parameter | Value | Reasoning |
|---|---|---|
| Segment duration | 2 s | Short enough that a seek or quality switch costs little and that the start handshake is quick; long enough to keep request overhead and playlist size sane for a two-hour film. |
| Why not 1 s | Rejected | Doubles request count and per-segment overhead, hurts cache efficiency, and gives the ABR estimator less throughput signal per sample — more rung flapping, not less latency. |
| Why not 6 s | Rejected | A re-arm would cost up to six seconds of buffering and a mid-film quality switch becomes visible. Bad trade for a product whose promise is joint timing. |
| Start gate | ≥ 3 s buffered on both clients, or 6 s elapsed | Waiting for a real buffer prevents an immediate stall; the timeout prevents one weak connection from holding the evening hostage. |
| Steady-state target | 18–24 s ahead | Deep enough to ride out a mobile handover, and irrelevant to sync accuracy because position is authoritative, not buffer-derived. |
| Rebuffer response | Courtesy pause for both | In a two-person room, the correct behaviour when one person stalls is to wait, then resume together — not to let them fall a minute behind. |
| ABR | Per-client, independent | Each device picks its own rung from its own throughput estimate. Sync lives on the media timeline, so mismatched quality is fine. |
| Rung cap on weak links | Sticky lower rung for 60 s after two stalls | Stops the estimator from optimistically climbing back and stalling again, which is the main cause of repeat rebuffering. |
Delivery. The origin is Nginx or Caddy in front of MinIO with an on-disk segment cache, which is enough for two viewers. A CDN or a second cache node near the viewers is a pure optimisation and is never required by the protocol — the media gate signs URLs that any HTTP cache can serve, so it can be added later without a code change.