Skip to main content

Flam Multiplayer Sync

Flam Project

TypeScript

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

PathWhy it matters
server/src/ws/frame.tsRFC 6455 codec. The incremental parser is the heart of it.
server/src/ws/handshake.tsThe HTTP upgrade and the Sec-WebSocket-Accept hash.
client/src/interpolation.tsSnapshot interpolation: what makes remote cursors look alive.
server/src/room.tsPresence, the 20Hz tick, and single-serialisation fan-out.
server/src/protocol.tsThe 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

TypeShapeNotes
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

TypeShapeNotes
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:

perfectawful
Round trip0 ms339 ms
Snapshots held18 (1062 ms)10 (683 ms)
Clock offset−1 ms−46 ms
Buffer stateinterpolatingextrapolating

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.md sets 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 /healthz first 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