Wire protocol
Silt runs two lanes over one WebTransport (QUIC) connection. The protocol is
self-describing, minimal, and carries JSON payloads. You don’t need this page to
use @siltrun/client — it’s here if you’re implementing a client or server, or
just want to know exactly what’s on the wire.
Lane 1 — Presence (datagrams)
Section titled “Lane 1 — Presence (datagrams)”Unreliable, latest-wins, droppable. Carries the ~20Hz position firehose.
Framing: [1 byte type][JSON payload]
| type | direction | payload | meaning |
|---|---|---|---|
0x01 PRESENCE | client → server | {state} | this peer’s latest presence. The server stamps the peer’s id (known from the QUIC session). |
0x01 PRESENCE | server → client | {id, state} | a peer’s latest presence, relayed to all others. |
0x02 STATE | server → client | {tick, state} | authoritative state from a compute room, per tick, latest-wins. Relay rooms never send it. |
- The 1-byte tag lets a second datagram type coexist with presence without a wire
break — which is exactly how the
0x02STATE lane was added. - An unknown datagram type is dropped, so the lane stays forward-compatible.
- The server drops presence datagrams from a session that hasn’t sent
helloyet (the presence-before-hello race). The lane is droppable, so this is harmless — latest-wins recovers on the next datagram. - No buffering, and no id needed client→server: the session identifies the peer,
and the server stamps
idon relay.
Lane 2 — Reliable (one bidi stream per peer)
Section titled “Lane 2 — Reliable (one bidi stream per peer)”Ordered, no drop or reorder. Carries control frames and discrete events. The client opens one bidirectional stream right after connect; the server accepts it. All reliable traffic for that peer flows both ways on this single stream.
Framing: [4-byte big-endian uint32 length][JSON frame]. Each frame is a
JSON object with a type tag: {"t": "<type>", ...}.
Client → server
Section titled “Client → server”t | fields | meaning |
|---|---|---|
hello | {id} | First frame. Stable, client-supplied identity. Binds session → id. |
event | {event} | a discrete reliable event to broadcast to others. |
bye | — | clean voluntary leave (sent by room.close()). |
Server → client
Section titled “Server → client”t | fields | meaning |
|---|---|---|
snapshot | {self, peers: [{id, state}]} | sent immediately after hello: who’s here plus each peer’s last presence (state may be null if a peer hasn’t sent presence yet). |
join | {id} | a new peer joined. |
leave | {id, reason} | a peer left. reason: "left" (clean bye) or "timeout" (session died without bye). |
event | {from, event} | a peer’s reliable event, relayed. |
Compute rooms
Section titled “Compute rooms”A relay room just relays presence and events between peers. A compute room also runs server-authoritative code and broadcasts an authoritative state. The extra wire is additive — relay rooms omit all of it, so relay behavior is byte-for-byte unchanged.
snapshotgains{mode: "compute", state, tick}. A compute room’s join snapshot carriesmode: "compute", the current authoritativestate(the join keyframe), and thetickthat state is authoritative at. The client flips into compute mode on seeing this and surfaces the keyframe as the first"state"event.0x02STATE datagram (server → all peers, per tick, latest-wins) carries{tick, state}— the authoritative state, delivered on the same droppable datagram lane as presence.- Input envelope (client → server, compute rooms only): the
0x01datagram payload becomes{seq, clientTick, data}, wheredatais what you passed topresence.set.seqis a monotonic reorder guard (the server drops any packet withseq≤ the last it saw). In a relay room the payload stays the raw state, unwrapped. - Server events use the existing reliable
eventframe with a reserved sender:{t: "event", from: "@server", event}.
Room-info discovery
Section titled “Room-info discovery”joinRoom also accepts a plain http(s):// URL. It probes
GET <url>/.well-known/silt, and if that answers:
{ "wtEndpoint": "https://…/room/<name>", "certHash": "<hex sha-256> | null", "mode": "compute" }it dials wtEndpoint with certHash pinned. On any failure — network error,
non-2xx, bad JSON, missing wtEndpoint, or a timeout — it falls through to
treating the URL as the direct WebTransport endpoint. Existing callers that pass a
WebTransport URL directly are unaffected.
Identity & reconnect
Section titled “Identity & reconnect”hello.id is stable and client-supplied. Reconnecting is just calling
joinRoom again with the same id.
Supersede suppression. When a hello arrives for an id that already has a
live session in the room, the server replaces it in place: it swaps the session
pointer for that id and sends the newcomer its snapshot, but does not broadcast
a leave then join to others — no id flap. Presence and events simply resume on
the new session. The old session’s teardown is marked superseded so its
error-driven leave is suppressed.
If the old session is detected dead (timeout) before the rejoin arrives — a
real network gap — peers honestly see leave: "timeout" and later join. That’s
correct, not a flap.
Membership atomicity
Section titled “Membership atomicity”Registering a new peer, building its snapshot, and broadcasting its join all happen under one room mutex. A peer joining concurrently can’t be lost from a snapshot or miss a join — there’s no lost-update window.
Leave vs timeout
Section titled “Leave vs timeout”- A
byeframe received (or stream FIN after a priorbye) →reason: "left". - A stream/session error with no
bye→reason: "timeout".
v0 known limitations
Section titled “v0 known limitations”- The two-lane model — the concepts behind the framing.