Multiplayer Run Sync (Leader/Participant)
This skill documents how “shared runs” work in WOD Brains, and the common gotchas when changing the run engine, API, or UI.
Mental model (treat it like a game simulation)
- •Authoritative event log: the run is a deterministic function of
timerPlan + events + simNowMonoMs. - •Monotonic timeline: run timing uses a monotonic clock, not wall-clock time.
- •Fixed timestep: simulation advances at 10Hz (
tickMs=100) and renders smoothly at 60fps using interpolation. - •Leader vs Participant:
- •Leader (run owner) is the only client allowed to send authoritative events.
- •Participant is view-only for authoritative run state, but can rep-count locally.
Where the authoritative truth lives
Durable Object (DO): apps/worker/src/run-actor.ts
Responsibilities:
- •WebSocket hub: accepts WS connections and broadcasts snapshots.
- •Presence:
onlineCount = this.ctx.getWebSockets().length(counts each tab/device connection). - •Authoritative monotonic time:
- •DO maintains a monotonic “now” (
serverNowMonoMs) usingperformance.now()deltas. - •It persists a base (
clockstorage key) so it can recover across hibernation.
- •DO maintains a monotonic “now” (
- •Authoritative run settings:
- •
timeScaleis part of run settings and is included in every snapshot. - •Settings updates are rejected after
start(409) to keep determinism simple.
- •
Snapshot envelope fields (key ones):
- •
serverNowMonoMs(number, int-ish) - •
onlineCount - •
timeScale - •
timerPlan,events, andderived
Worker API: apps/worker/src/app.ts
Key routes:
- •
GET /api/runs/:runId→ proxies DO/snapshot - •
GET /api/runs/:runId/access→{ canControl: boolean }based ontimer_runs.ownerUserId - •
POST /api/runs/:runId/events:- •requires session
- •enforces leader-only via
timer_runs.ownerUserId - •returns 403
{ error: 'view_only' }for participants - •gotcha:
atMsmust be an int (round it before sending to DO)
- •
PATCH /api/runs/:runId/settings:- •leader-only, proxies DO
/settings - •used to set
timeScalebefore start
- •leader-only, proxies DO
Web client sync engine
Entry point: apps/web/src/pages/run.ts
Responsibilities:
- •Role gating:
- •Fetches
GET /api/runs/:runId/accessand setscanControl. - •If a participant tries to send events, server returns 403
view_only; UI flips to participant mode.
- •Fetches
- •Monotonic clock sync:
- •Receives snapshots with
serverNowMonoMs. - •Maintains an offset
serverPerfOffsetMsso:- •
estimatedServerNowMonoMs() = performance.now() + serverPerfOffsetMs
- •
- •Uses smoothing (
updateMonotonicOffset) to avoid time jumps.
- •Receives snapshots with
- •Fixed timestep sim loop:
- •Uses
advanceFixedStepeach animation frame:- •accumulator collects
dt - •applies 0..N ticks of
tickMs=100 - •applies bounded correction per tick (
maxCorrectionPerTickMs)
- •accumulator collects
- •Renders timer with interpolation within the current tick.
- •Uses
- •Scheduled start & countdown:
- •Leader schedules start by posting a
startevent in the future:- •
startAt = round(estimatedServerNowMonoMs()) + 10_000
- •
- •Countdown overlay derives from
startedAtMs - simNowMonoMsso all clients show the same countdown.
- •Leader schedules start by posting a
- •Presence + role UI:
- •Corner shows
Participant · N onlinefor participants. - •Corner only shows
Leaderlabel whenonlineCount > 1. - •TimeScale display only shows
x NwhentimeScale !== 1.
- •Corner shows
- •Controls visibility:
- •Participant should not see disabled leader controls; hide them.
- •Rep counting exception:
- •Rep splits are local-only (stored in
localStorage) for participants. - •Leader may still emit authoritative
splitevents, but UI merges local + server splits for display.
- •Rep splits are local-only (stored in
Shared simulation helpers: packages/core/src/sim-clock.ts
- •
updateMonotonicOffset(prev, sample, smoothing):- •sample =
{ serverNowMonoMs, clientPerfNowMs } - •use smoothing to prevent discontinuities
- •sample =
- •
advanceFixedStep(state, dt, targetNow, config):- •fixed-step tick application
- •bounded per-tick correction toward target time
- •
maxCatchupTicksprevents spiral-of-death
timeScale (hidden feature)
Policy:
- •No visible leader control in the UI.
- •Enable only via the run URL querystring:
- •
/r/<runId>?timeScale=100
- •
- •Client behavior:
- •If leader and run has not started, apply querystring value once by calling:
- •
PATCH /api/runs/:runId/settings { timeScale }
- •
- •Participants can see
x Nif it’s not1(but cannot change it).
- •If leader and run has not started, apply querystring value once by calling:
Gotchas:
- •Don’t accidentally expose a “Speed” control (or any clickable timeScale UI).
- •Only allow setting timeScale pre-start; keep it constant for determinism.
Share UX
- •Run page start overlay includes an Invite friends button:
- •Uses
navigator.share({ title, text, url })when available. - •Fallback: copy run URL to clipboard.
- •Share text:
Workout at the same time with friends. You're invited to join my workout live on WOD Brains. - •ARIA label:
Invite friends to this workout
- •Uses
- •Workout definition page includes a Share workout button (header icon):
- •Shares
/w/<definitionId>(so others can create their own runs). - •Share text:
Try this workout on WOD Brains. Start it and invite friends to join live.
- •Shares
Common gotchas before changing anything
- •Never switch timing back to
Date.now()for the simulation clock. Wall time is only for DB/history. - •Ensure all event timestamps are ints:
- •Schema validation expects integer
atMs→ round in the worker API and in the client event generation.
- •Schema validation expects integer
- •Start overlay intercepts pointer events:
- •Tests that click UI elements must click start overlay first (or ensure it’s hidden).
- •Participant view:
- •Do not show disabled leader controls; hide them to avoid confusing UX.
- •timeScale:
- •Must be applied consistently to all clients; don’t let clients diverge.
- •DO hibernation:
- •Monotonic clock base must survive restarts; keep the persisted base logic intact.
Tests to run (and why)
Core
- •
pnpm -C packages/core test- •Covers
deriveRunStatepre-start behavior and sim-clock correctness.
- •Covers
Worker
- •
pnpm -C apps/worker test- •Covers leader-only enforcement, WS presence, and snapshot fields.
Web E2E (Playwright)
- •
pnpm -w exec playwright test --config /Users/jd/src/wodbrains/playwright.config.ts
Notes:
- •Playwright runs the worker with
STUB_PARSE=1. - •If you change the run flow (autostart, overlays), expect to update tests to click
#startOverlay. - •Share tests can stub Web Share API via
page.addInitScriptand assert the shared text/url.