Architecture
How the pieces fit: signalling, peer topology, what crosses the wire.
The Go server only does signalling. Once two peers have found each other, scroll position and content travel directly between them over WebRTC data channels; the server never sees either.
┌──────────── Go server (:8080) ────────────┐
│ /ws signalling relay, one room per session │
│ /ice STUN/TURN config │
│ / the embedded frontend │
└───────────────────┬──────────────────────────┘
│ SDP/ICE only
┌────────────────────────────────┼────────────────────────────────┐
▼ ▼ ▼
[Control page] [Viewer: window] [Viewer: another device]
one RTCPeerConnection ───── scroll (unreliable) + control (reliable) ─────┘
per viewer, plus a local
preview iframe over postMessageStar topology. The control page holds one RTCPeerConnection per viewer and
is always the WebRTC perfect-negotiation impolite side; viewers are always
polite and only ever talk to the controller, never to each other. That keeps a
viewer's job simple no matter how many others are connected.
Sync state, not pixels. Both ends render the same HTML; what crosses the
wire is a 0..1 scroll ratio, not video. A ratio rather than a pixel offset is
what lets a phone, a 4K display and the control page's scaled-down preview all
sit on the same line.
One pacer, at full rate. The driving viewer's position goes out on every one of ~60 samples a second and is applied instantly at the far end. That is why it feels smooth; an earlier attempt to send 4/sec and interpolate between samples was strictly worse. Two viewers measured under the current scheme track to within a pixel.
The preview is a real miniature. The iframe renders at the previewed viewer's actual pixel size and is CSS-scaled down, so text wraps exactly as it does on that display. Sizing it to the small on-screen box instead would reflow the content and show the operator something no viewer is rendering.
Channels
Three data channels per peer, negotiated with fixed ids so both ends create them identically without a handshake:
| Channel | id | Delivery | Carries |
|---|---|---|---|
scroll |
0 | unordered, unreliable | the 0..1 position, latest-wins by sequence number |
control |
1 | ordered, reliable | content, settings, theme, clock, driver grants, dimensions |
file |
2 | ordered, reliable | a PDF, as a header, raw chunks and a trailer |
Scroll samples are deliberately on an unreliable channel: a dropped one is
followed by another a frame later, and re-sending a stale position is worse than
skipping it. Anything that must arrive exactly once goes on control, which
also queues sends made before the channel finishes opening.
Rooms, links and control
A room id identifies a session and is in the control page's URL, so a refresh rejoins the same room. The viewer link carries only that id.
Control of a room is held with a separate key, generated by the control page
and kept in localStorage — never in the viewer link. The first controller into
a room claims it; any controller afterwards must present the same key, compared
in constant time. Without this, since a reconnecting controller displaces the
sitting one, anybody who was sent a viewer link could take over the session and
push their own content to every display.
A new controller evicting the sitting one is what makes an operator's refresh reconnect rather than orphaning every display.
What travels
src/frontend/scripts/protocol.ts is the whole vocabulary, and the same message
shapes are used over WebRTC and over postMessage to the preview iframe — so
the viewer code has one switch regardless of transport.
Messages run controller → viewer, with three exceptions: a viewer reports its
own dims (size, and whether it is local), the driving display reports a
text-scale when someone pinches it, and the preview iframe reports its scroll
ratio upward so the operator can scrub from it.
Two things are worth knowing about the shapes rather than the list:
- The countdown travels as state, not as commands. A
runningflag and aremainingMs, not start/stop — which is what lets it be replayed to a display that joins late and restored after a refresh. A remaining duration rather than an absolute deadline, because an epoch stamped by the control page would be read against the receiving device's clock. - A message too big for one send is split. A data channel refuses anything
past the SCTP association's limit — 256KB in Chrome — and a pasted service
script goes well past it, so
controlframes.tssends an oversized message as parts and the receiver reassembles them. Anything that fits still travels as one plain message, which is everything but the script and a large theme. - A theme carries its own CSS. A custom layout exists only in the operator's browser, so viewers are sent the text; the class name rides in the same message so it cannot be applied before the stylesheet it needs.
Where the code is
src/backend/ is four files: main.go (wiring, CSP, embedding the frontend),
hub.go (rooms and peers), signal.go (the WebSocket relay) and ice.go
(STUN/TURN config).
The frontend is split so the parts worth testing can be: each module holding
arithmetic or state is DOM-free with a _test.ts beside it, and its DOM half is
a separate file — commands.ts / paletteControls.ts, doc.ts /
docControls.ts, gamepad.ts / gamepadControls.ts, settings.ts /
settingsControls.ts, timer.ts / clock.ts.
../CLAUDE.md documents each module and, more usefully, the constraints that are invisible until you break them.