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.

Unreliable, latest-wins, droppable. Carries the ~20Hz position firehose.

Framing: [1 byte type][JSON payload]

typedirectionpayloadmeaning
0x01 PRESENCEclient → server{state}this peer’s latest presence. The server stamps the peer’s id (known from the QUIC session).
0x01 PRESENCEserver → client{id, state}a peer’s latest presence, relayed to all others.
0x02 STATEserver → 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 0x02 STATE 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 hello yet (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 id on 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>", ...}.

tfieldsmeaning
hello{id}First frame. Stable, client-supplied identity. Binds session → id.
event{event}a discrete reliable event to broadcast to others.
byeclean voluntary leave (sent by room.close()).
tfieldsmeaning
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.

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.

  • snapshot gains {mode: "compute", state, tick}. A compute room’s join snapshot carries mode: "compute", the current authoritative state (the join keyframe), and the tick that state is authoritative at. The client flips into compute mode on seeing this and surfaces the keyframe as the first "state" event.
  • 0x02 STATE 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 0x01 datagram payload becomes {seq, clientTick, data}, where data is what you passed to presence.set. seq is a monotonic reorder guard (the server drops any packet with seq ≤ the last it saw). In a relay room the payload stays the raw state, unwrapped.
  • Server events use the existing reliable event frame with a reserved sender: {t: "event", from: "@server", event}.

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.

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.

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.

  • A bye frame received (or stream FIN after a prior bye) → reason: "left".
  • A stream/session error with no byereason: "timeout".