Architecture
How two devices stay on the same line: anchors, transports and the wire protocol.
The problem with scroll offsets
The obvious way to sync two prompters is to send a scroll position. Device A is at 2,140 pixels; tell device B to go to 2,140 pixels.
That works only while both devices are the same shape. In practice they never are: one is a laptop in landscape at 72px type, the other is a phone in portrait at 19px. The same pixel offset is a completely different sentence on each, and the whole promise of the product is that the two screens show the same words.
Normalising helps less than it looks. A fraction of total scroll height is still a fraction of a different layout, and the error grows with the length of the script.
Anchors
Teleprompt syncs a position in the text instead. A script is split into an ordered list of blocks by a pure function of the source string, so every device produces exactly the same list. A position is then two numbers:
type Anchor = {
blockIndex: number; // which block is on the reading line
blockFraction: number; // 0 = its first line, 1 = its last
};Each device resolves that against its own layout:
// anchor -> pixels, on this device
position = block.offsetTop + anchor.blockFraction * block.height;
// pixels -> anchor, on this device
blockIndex = binarySearchForBlockContaining(position);
blockFraction = (position - block.offsetTop) / block.height;Because the block list is identical everywhere and the geometry is local, a phone and a monitor land on the same sentence without either one knowing anything about the other’s screen.
Why the script is snapshotted
Saving an edit therefore writes the new text into every live room using that script, in the same transaction as the script itself, and bumps the room’s
contentRevision. Devices notice the new revision on their next poll and refetch. The reading position is carried across by clamping the block index rather than reset, so fixing a typo further down does not throw the reader back to the top.The room state
One object describes everything about a live session. It is what gets broadcast, and what gets persisted:
type PrompterState = {
anchor: Anchor;
isPlaying: boolean;
speedWpm: number; // 40-320, words per minute
fontSize: number; // 20-160 px
lineHeight: number; // 1.1-2.4
contentWidth: number; // 40-100 %
readingLine: number; // 0.15-0.7 of viewport height
flipHorizontal: boolean;
flipVertical: boolean;
showReadingLine: boolean;
theme: "night" | "amber" | "paper";
revision: number; // monotonic; higher wins
updatedAt: number; // epoch ms, stamped by the writer
};Speed is stored in words per minute and converted to pixels per second at render time, using the measured height of the script and its spoken word count:
pixelsPerSecond = (speedWpm / 60) * (scrollableHeight / spokenWords)So the same pace setting means the same delivery speed on every device, and changing the type size mid-take does not change how fast you have to talk.
Three transports
Devices always meet on a Supabase Realtime channel named by the room’s secret key. That works from anywhere and needs no luck with NAT traversal, and it carries presence, so each device knows who else is in the room and what role they took.
As soon as two devices see each other, they exchange WebRTC offers over that channel and try to open a data channel directly between them. Which side offers is decided by comparing the two device keys, so both sides reach the same answer with no extra round trip.
Sending prefers the direct channel and falls back per peer: every peer with an open data channel gets the message directly, and if any peer is missing, the message also goes out over the relay. Receivers deduplicate on (from, seq), so arriving twice is harmless.
Signalling itself always goes over the relay. It is what bootstraps the direct path, so it cannot depend on it.
Underneath both sits a third path that is not really a transport at all. Some networks will not carry a WebSocket, and a phone returning from a pocket often has a socket that is open but dead - nothing errors, the client still believes it is subscribed, and messages simply stop. A watchdog notices the silence, because pings run every five seconds and a peer that has stopped answering two of them is not there. It rejoins the channel, and in the meantime the device reads the room’s saved state over HTTPS instead.
That path is coarse, a couple of seconds behind, and it is deliberately not a way of streaming position. It works because the snapshot carries the pace and the play flag as well as the anchor, so the follower extrapolates between polls and the text keeps moving at the right speed rather than freezing and lurching. The connection badge reads Catching up while this is happening, and the driver flushes more often so there is something fresh to read. It switches itself off the moment the channel recovers.
The wire protocol
Every message is a small JSON object, validated with the same Zod schema on both ends. Nothing that arrives off the network is trusted.
| Type | Direction | Carries |
|---|---|---|
hello | Any device, on join | Device key, label, role. The driver answers with state. |
state | Driver to everyone | The full PrompterState, about ten times a second while scrolling. |
cmd | Follower to driver | play, pause, toggle, seek, step, scrub, speed, settings, restart, requestState, end. |
reload | Any device | The room’s script snapshot changed; refetch it. |
ping / pong | Any device | Round-trip latency, which is the number in the connection badge. |
signal | Device to one device | WebRTC offer, answer or ICE candidate. |
Who drives
Exactly one device integrates time into position: the display that has been connected longest, decided by comparing presence timestamps and falling back to the device key on a tie. Every device evaluates that rule against the same presence list, so they all reach the same answer independently.
Every other device is a follower. Followers do not simulate playback from their own clock; they send commands and render what they are told. This is what keeps two displays from drifting apart over a long read.
Dead reckoning
Position updates arrive about ten times a second. Rendering them directly would look like a slideshow, so followers extrapolate:
elapsed = (now - snapshot.receivedAt) / 1000;
predicted = anchorToPosition(snapshot.anchor)
+ (snapshot.isPlaying ? pixelsPerSecond * elapsed : 0);
// ease toward the prediction every frame, or snap if we are far out
position += (predicted - position) * 0.18;The result is smooth 60fps motion driven by 10Hz data, with drift corrected continuously rather than in visible jumps. A gap larger than about two and a half screens is treated as a seek and snapped.
None of this goes through React. The engine writes transform: translate3d(...) straight to the DOM from a requestAnimationFrame loop, and the block list is memoised on the source text, so a twenty-minute take can run without a single re-render.
Durability
The driving device writes the state back through tRPC every six seconds and once more when it disconnects. Writes carry the state’s revision number and the server rejects anything older than what it already has, so two devices flushing at once cannot move the room backwards.
That is the only reason the database is in the loop at all. The realtime path is the fast path; persistence exists so a reload lands within a sentence of where you were.
Access control
- Every tRPC procedure that touches a room checks that the room belongs to the signed-in user. There is no unauthenticated path to a room.
- The realtime channel is named by a 256-bit random key stored on the room. It is returned by exactly one endpoint, and only to a signed-in device on the owning account.
- The join code is a lookup key scoped to your account, not a credential. Someone with the code and no account gets nothing.
- The browser holds only the Supabase publishable key, and never reads or writes a table with it. All data access goes through tRPC on the server.
- Every inbound message is schema-validated before it is acted on.
Where the code lives
| Path | What is in it |
|---|---|
src/lib/markdown/blocks.ts | The deterministic splitter. Pure, no environment input. |
src/lib/prompter/state.ts | State shape, limits, themes, anchor type. |
src/lib/realtime/protocol.ts | Message schemas and the offer/answer tie-break. |
src/lib/realtime/link.ts | Channel, presence, deduplication, per-peer send. |
src/lib/realtime/peer.ts | The WebRTC mesh and its failure handling. |
src/components/prompter/engine.ts | Measurement, the frame loop, anchor conversion, dead reckoning. |
src/server/api/routers/room.ts | Room lifecycle, ownership checks, state persistence. |
If you want to change how any of this behaves, start with Contributing.