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 @silt/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. |
- The 1-byte tag is kept even though presence is the only datagram type today, so a second datagram type can be added without a wire break.
- 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. |
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.
- Run your own — the reference relay server.