The two-lane model

Every Silt room gives each peer two lanes over one WebTransport connection. Choosing the right lane for each message is the core skill of building on Silt.

PresenceEvents
TransportWebTransport datagramsOne long-lived bidi stream
DeliveryUnreliable, droppableReliable, no drop
OrderingLatest-wins (no order)Ordered
RateHigh — the ~20Hz firehoseDiscrete, as needed
SDKroom.presence.set(state)room.events.send(event)
Receiveroom.on("presence", cb)room.on("event", cb)
Use forCursor / camera / positionChat, actions, state changes

Presence carries a peer’s latest state, published as fast as you like. It is unreliable on purpose: if a datagram is lost, Silt does not retransmit it, because the next presence.set already supersedes it. Old positions have no value once a newer one exists.

// publish 20+ times a second; drops are harmless
room.presence.set({ x, y, heading });
room.on("presence", (peerId, state) => {
// state is whatever the sender last set — latest-wins
});
  • Latest-wins: the server keeps each peer’s last presence and relays it to everyone else. A new peer’s join snapshot includes each peer’s last presence.
  • Droppable: calling presence.set before the connection is ready (or between reconnects) is a no-op, not an error. The last value you set is re-published automatically when the connection comes back.
  • Presence callbacks fire for other peers, never your own echoes.

Events are reliable and ordered. They flow on a single bidirectional stream per peer, so they arrive in the order you sent them and nothing is dropped.

room.events.send({ type: "absorb", target: "bob" });
room.on("event", (peerId, event) => {
// delivered in order, exactly as sent
});

room.events.send throws if the room isn’t currently connected, so you know when a critical message couldn’t be queued.

A plain WebSocket gives you only the reliable lane. Push a 20Hz position firehose through it and a single lost packet head-of-line-blocks everything behind it: the connection stalls while TCP retransmits a position you no longer care about. Every later message — including the ones that mattered — waits.

Silt’s two lanes are independent. A lost presence datagram never blocks a reliable event, and a flood of presence updates never delays a chat message. That separation is only possible because WebTransport runs on QUIC, which gives you both an unreliable datagram channel and independent reliable streams over a single connection.

If losing this message is fine because a newer one is coming, it’s presence. If it has to arrive, in order, it’s an event.

Both lanes above are what a relay room gives you: Silt relays presence and events between peers and runs no logic of your own. For a relay room you bring a client and nothing else — Silt runs the transport, routing, and room lifecycle.

When some logic has to be authoritative — a score, a referee, hit detection that can’t be left to clients — it runs in a compute room: your server code runs inside the room and broadcasts an authoritative state. The demo’s referee runs in a compute room today.

The client surface barely changes. The room announces its mode in the join snapshot; in a compute room, presence.set becomes your input to the authoritative simulation, and the room’s state arrives on a third callback:

room.on("state", (update) => {
render(update.state); // authoritative state at update.tick
});