Live Room: real-time multiplayer cursor & reaction sync
Many viewers on one surface, seeing each other move and react live. Built on
raw WebSockets with a hand-written RFC 6455 implementation: no ws, no
Socket.IO, no Yjs, no PartyKit, and no runtime dependencies on either side.
Start here: BEFORE-YOU-START.md is the short version, including a 60-second demo and why there's a Dockerfile instead of a live link.
Test it: docker compose up --build, then open http://localhost:8787 in
two tabs.
Quick start
git clone <repo-url> && cd flam-multiplayer-sync
npm install
npm run dev
Then open http://localhost:5173 in two or more tabs. Each tab is a separate participant.
npm run dev starts the server on :8787 and the Vite client on :5173.
To run the production build exactly as it is deployed: one process serving both the client and the WebSocket endpoint:
npm run build
npm start
Then open http://localhost:8787.
Docker
docker compose up --build # start
docker compose down # stop
Same thing in a container, on the same port. No restart policy, so it stays
stopped when you stop it. Multi-stage build: TypeScript and
Vite are build-time only, so the runtime image is the compiled server, the
built client and nothing else. The healthcheck is baked into the image, so
docker run on its own reports health.
Rooms
Everyone lands in watch-party-42 by default. Add ?room=<name> to use a
different one, and ?name=<label> to set your display name:
http://localhost:8787/?room=broadcast-a&name=Shashank
Rooms are fully isolated: there is a test asserting cursors cannot leak between them.
Tests
npm test
62 tests. The unit tests cover the frame codec and protocol validation; the
integration tests spawn the real server and drive it with Node's built-in
WebSocket client, which is an independent implementation (undici). If the
handshake hash or the framing were wrong, those tests could not connect at all, so they double as proof that a browser will connect too.
A note on the structure
The brief's example layout shows client/src/App.tsx. There is no React here
and the entry point is main.ts instead.
React would own a single <canvas> element and re-render nothing: the
interpolation loop runs on requestAnimationFrame outside any component
lifecycle, and the participant list is a dozen <li>s. Adding it would mean a
dependency and a lifecycle to reason about without changing a line of the sync
engine. The FAQ makes React optional and requires the sync logic to be
framework-agnostic, which it is: connection.ts, interpolation.ts and
render.ts have no DOM framework in them at all.
Everything else matches: server/src/{server,room,protocol}.ts and
client/src/{connection,interpolation,render}.ts.
What to look at first
| Path | Why it matters |
|---|---|
server/src/ws/frame.ts | RFC 6455 codec. The incremental parser is the heart of it. |
server/src/ws/handshake.ts | The HTTP upgrade and the Sec-WebSocket-Accept hash. |
client/src/interpolation.ts | Snapshot interpolation: what makes remote cursors look alive. |
server/src/room.ts | Presence, the 20Hz tick, and single-serialisation fan-out. |
server/src/protocol.ts | The wire format, shared by both sides. |
ARCHITECTURE.md has the data-flow diagram and the scaling
discussion. docs/adr/ has the decision records: what I chose,
what I turned down and why. docs/bugs.md is the running log of
what broke, including the things nobody would have found.
Protocol
JSON over text frames. Coordinates are normalised floats in [0,1], never
pixels, so a phone at 390px and a monitor at 2560px agree on where "the same
place" is. Values are rounded to 4 decimals: sub-pixel at 4K, and shorter on
the wire.
Client → server
| Type | Shape | Notes |
|---|---|---|
join | { t, room, name?, resumeId? } | First message on every connection. resumeId reclaims a previous identity. |
cursor | { t, x, y, seq } | Throttled to 30Hz; not sent at all if the cursor has not moved. |
reaction | { t, x, y, kind, seq } | kind is one of heart fire clap star. |
rename | { t, name } | Live display-name change. Id and colour are unaffected. |
blur | { t } | Pointer left the surface; stop reporting this cursor. |
ping | { t, echo } | echo is the client's own clock, bounced back untouched. |
Server → client
| Type | Shape | Notes |
|---|---|---|
welcome | { t, you, room, participants[], now, tickMs, renderDelayMs } | Full snapshot, sent once on join. |
join | { t, peer } | Someone arrived. |
away | { t, id, graceMs } | Their socket dropped; seat held for graceMs. |
leave | { t, id, reason } | Gone for good. reason is timeout. |
tick | { t, now, cursors: [[id, x, y], ...] } | All cursor state, 20×/sec. |
reaction | { t, id, x, y, kind, now } | Relayed immediately, not batched. |
rename | { t, id, name } | Somebody renamed themselves. Same id, same colour. |
pong | { t, echo, now } | Round-trip probe reply. |
error | { t, code, message } | Rejected message; the connection stays open. |
Throttling and batching
Three separate mechanisms, because three different problems:
Outbound, client side. pointermove fires at up to 120Hz. Every event
overwrites a single pending field; a timer flushes it at most every 33ms
(30Hz). If the position is unchanged since the last flush, nothing is sent at
all: an idle participant costs zero bandwidth. This is safe only because a
cursor position is state: dropping intermediate values loses nothing, since
the receiver interpolates between samples anyway.
A setInterval rather than requestAnimationFrame, deliberately: rAF stops
in a background tab, which would make a hidden tab appear frozen to everyone
else.
Server fan-out. The server does not relay cursor messages. It stores the
latest position per client and emits one tick containing everybody, 20 times
a second. Relaying each message to each peer is N senders × N receivers =
O(N²) messages/sec; batching makes it O(N) messages/sec regardless of
how fast anyone moves.
The payload is serialised once per tick, outside the send loop: 1
JSON.stringify and N writes, not N stringifies.
Reactions bypass both. They are events, not state: rare, and each one carries information that batching would destroy. Waiting up to 50ms for the next tick would add lag to the one interaction where the user is actively watching for feedback.
Using the demo
Your identity is in the top bar: a coloured dot, your name, and a you
tag. The name field is editable and the change propagates live to everyone: your id and colour stay the same, so peers see the same cursor rename itself
rather than one person leaving and another arriving.
Reactions fire straight from the buttons: press heart and a heart goes out. There is no mode to enter and nothing to turn off afterwards.
Each one is emitted at your current cursor position, so it visibly comes out of you and everyone can see who sent it. If your pointer is off the surface it comes from near the bottom edge instead. Clicking the surface itself only moves your cursor.
(An earlier version made you arm a reaction and then click a target. That was a mistake: see ADR 0005.)
Leaving the surface hides your cursor for everyone else rather than leaving it frozen where you abandoned it. You stay in the participant list throughout: being in the room and pointing at something are different facts.
Note on scope: this is the cursor and reaction sync assignment. There is no drawing or erasing here; the surface is for pointing and reacting.
Interpolation
The approach: render deliberately in the past.
Snapshots arrive 20×/sec and irregularly; the display refreshes at 60Hz. Drawing each snapshot as it lands would step visibly and turn every hiccup into a freeze-then-teleport.
So the client renders everything RENDER_DELAY_MS = 100ms behind the server's
clock. At almost any instant it therefore holds a snapshot on both sides of
the moment being drawn, and can interpolate between two known-good positions
rather than guessing past the newest one.
snapshots: S1--------S2--------S3--------S4 arriving, ~50ms apart
render head: ^
|
drawn here, between S2 and S3,
while S4 is already safely in hand
The tradeoff, stated honestly: remote cursors are ~100ms stale. In exchange, a single late or dropped packet is usually invisible, because the buffer had already covered for it. For shared presence that is the right trade: nobody can tell where your cursor "really" is. For a competitive shooter it would not be, which is why those use extrapolation with rollback instead.
Your own cursor is not drawn at all. The operating system is already
drawing it, and the canvas sets cursor: crosshair: painting a second one on
top gives you two pointers, and the painted one can only ever be as fresh as
the last frame, so it visibly trails the real one. Only other people's cursors
are rendered. Figma, Miro and FigJam all do the same.
When the buffer starves: the network is slower than the delay budget: the client extrapolates from the velocity implied by the last two snapshots, but only up to 60ms. Past that it holds position. A cursor that pauses reads as "the connection is struggling", which is true; one drifting confidently on stale velocity reads as a bug. Remote cursors dim while it is coasting.
Clock handling. The two machines never agree on the time and do not need
to. welcome seeds an offset estimate, refined from each ping/pong round trip
assuming a symmetric path, smoothed with an EMA so one delayed packet cannot
yank the render head and jolt every cursor at once. An error here only shifts
how far in the past we render by a few milliseconds.
Memory. Snapshots older than the render head plus 1s are pruned every time one arrives. Reactions are filtered out of their array as they expire. Nothing accumulates: the "unbounded buffering" failure mode is the one that stays invisible for the first ten minutes and then is not.
Seeing it work
The sidebar has a simulated network control that injects latency, jitter and
loss into inbound messages only, so remote cursors degrade while yours stays
instant. awful is 250ms + 220ms jitter + 12% loss.
This is more useful than Chrome's throttling presets, which shape bandwidth rather than jitter; WebSocket frames are small enough that "Slow 3G" barely touches them, while arrival-time variance is what smoothness actually depends on. Because each message gets an independent random delay, the simulator also reorders them, which exercises the sequence-number and snapshot-sorting paths.
The Connection panel shows what that does. Switching perfect → awful:
| perfect | awful | |
|---|---|---|
| Round trip | 0 ms | 339 ms |
| Snapshots held | 18 (1062 ms) | 10 (683 ms) |
| Clock offset | −1 ms | −46 ms |
| Buffer state | interpolating | extrapolating |
Round trip is measured downstream of the simulator, so it reflects what the app is actually experiencing rather than the real socket. There is no FPS counter: frame rate is what devtools is for, and a number that only ever reads 60 tells you nothing.
Failure handling
Tab closed or reloaded. These are indistinguishable: browsers send close
code 1001 for both navigating away and refreshing, so the server doesn't
pretend to tell them apart. It broadcasts away with the grace duration and
holds the seat. Peers show that person dimmed with three blinking dots rather
than removing them, and then get either a join (they came back, same id and
colour) or a leave (the window closed).
Announcing a departure immediately would be a guess, and it's the wrong guess every time somebody refreshes. The cursor disappears immediately either way: holding a seat isn't holding a cursor.
The client never runs its own countdown; it follows the server. Two clocks counting the same window would drift, and the indicator would clear at a different moment than the grace period actually ends.
Measured on localhost, a page reload produces about 45ms of away before the rejoin lands: below perception, which is the point. To actually see the indicator, close a tab or drop the network: that shows the full 12 seconds.
Network dropped. No packet is sent when a laptop lid closes or a phone leaves wifi: the socket stays "open" as far as the kernel is concerned, and TCP keepalive defaults are measured in hours. So the server pings every 7.5s at the WebSocket protocol level and drops any client that misses a full interval. Bounded removal time without relying on TCP noticing.
Reconnect. The client retries with exponential backoff (500ms → 8s) plus random jitter. Jitter matters more than it looks: if a server restarts with 50 clients attached they all disconnect in the same instant, and without jitter they all return in the same instant too, repeatedly: a thundering herd that can keep a recovering server down.
On reconnect the client presents its previous id. The server honours it if the identity is within a 12s grace window and no live connection is currently using it, so you come back as the same participant with the same colour instead of appearing as a stranger while a ghost lingers. Stale snapshots are dropped on reconnect, since they describe a session that moved on without us.
Identity is stored in sessionStorage, not localStorage: session storage is
per-tab, so five tabs are five participants. localStorage is shared across
tabs of an origin, which would make every tab fight over one identity and break
multi-tab testing in a way that looks like a sync bug.
Out-of-order messages. Every client stamps a monotonic seq; the server
discards anything at or below the highest it has seen from that client. TCP
already orders bytes within one connection, so this should not fire in
practice: it is there because it is nearly free, and it is what keeps the
design correct if the transport ever becomes unordered (WebTransport/UDP) or a
resuming client replays a buffered update. The client-side snapshot buffer
likewise inserts rather than appends an out-of-order snapshot, so its
ordering assumption cannot be violated.
Malformed messages. Every inbound message is validated at runtime before
anything touches it. A TypeScript interface is a compile-time promise our own
code makes to itself; it says nothing about bytes from a browser we do not
control. Unknown message types are rejected rather than ignored, so a version
mismatch surfaces as a visible error instead of cursors that mysteriously never
move. One bad message returns an error and leaves the connection open: a
client bug is not grounds for hanging up. A malformed frame is different: we
can no longer tell where the next frame begins, so the connection is closed.
Abuse. Payloads are capped at 1 MiB; a 64-bit length above the cap is
rejected without allocating. Coordinates are clamped rather than rejected
(1.0001 at the window edge is a rounding artefact, not an attack), but NaN
and Infinity are rejected outright: NaN propagates silently through arithmetic
until a cursor simply stops rendering with no error anywhere.
Browser support
Chrome, Edge, Firefox and Safari, desktop and mobile. Uses pointer events
(one code path for mouse, trackpad, pen and touch), canvas.roundRect
(Safari 16.4+), and color-mix() in CSS. No polyfills, no build-time
transpilation below ES2022.
Touch works: touch-action: none on the canvas stops the browser scrolling
instead of reporting pointer movement.
Known limitations
- No horizontal scaling. One process owns all rooms in memory. Two
instances would not share state.
ARCHITECTURE.mdsets out what would change. - No persistence. A restart empties every room. There is nothing worth persisting in this app, but a drawing surface would need it.
- No authentication. Room ids are the only access control, and they are guessable. Fine for the assignment, not for production.
- Clock sync assumes a symmetric path. Often wrong on mobile, where uplink and downlink differ. The effect is to render slightly further in the past than intended, which is harmless here.
- Extrapolation is velocity-only. No acceleration term, no rollback. It is a 60ms safety net, not a prediction system.
- The free Render tier sleeps. After ~15 minutes idle the first connection
waits for a cold start. Hit
/healthzfirst to warm it. - Reaction bursts are client-local after arrival. Everyone sees the same reaction at the same coordinates, but animation timing is per-client rather than frame-synced.
AI disclosure
Per the assignment FAQ: I used Claude (Anthropic) while building this.
- Used for: producing the first implementation of the RFC 6455 codec and handshake, the interpolation buffer, the room/tick layer and the demo UI; drafting the test suite; and writing this documentation.
- What I did with it: reviewed and iterated on the output, chose and adjusted the design decisions it is built around (render delay, tick rate, the state-vs-event split, resume semantics), and verified behaviour against the test suite and by hand with multiple tabs.
- I can explain any line in this repository, including the frame parser and the interpolation maths.
Time spent
About two days.
Roughly half of that went on the transport layer, which is the part with no shortcuts: the handshake, the frame codec, and the tests that feed the parser a frame one byte at a time. The interpolation buffer took an afternoon to get right and most of that was choosing the render delay rather than writing it. The rest was the demo, the failure cases, and fixing the things in docs/bugs.md.
Repository layout
flam-multiplayer-sync/
├── server/
│ ├── src/
│ │ ├── server.ts HTTP + upgrade + room registry
│ │ ├── room.ts presence, tick loop, fan-out
│ │ ├── protocol.ts wire format + runtime validation (shared)
│ │ └── ws/
│ │ ├── frame.ts RFC 6455 codec
│ │ ├── handshake.ts upgrade + Sec-WebSocket-Accept
│ │ └── socket.ts fragmentation, heartbeat, teardown
│ └── package.json
├── client/
│ ├── src/
│ │ ├── connection.ts transport, throttling, reconnect
│ │ ├── interpolation.ts snapshot buffer
│ │ ├── render.ts canvas
│ │ ├── netsim.ts simulated degradation
│ │ └── main.ts demo wiring
│ ├── index.html
│ └── package.json
├── docs/
│ ├── adr/ decision records
│ └── bugs.md running log of what broke
├── Dockerfile
├── docker-compose.yml
├── render.yaml one free web service, API and client
├── ARCHITECTURE.md
└── README.md