API reference
The complete public surface of @siltrun/client. This is the locked API — every
example on this site uses exactly these names.
import { joinRoom } from "@siltrun/client";joinRoom(url, options)
Section titled “joinRoom(url, options)”function joinRoom(url: string, options: JoinOptions): Promise<Room>;Connects to a room and resolves a Room. The promise resolves only
after the join snapshot has arrived, so room.peers is already populated with
everyone else in the room the instant joinRoom returns.
const room = await joinRoom("https://your-relay.example/room/lobby", { id: "alice",});url is the WebTransport URL of the room (an https:// origin plus the room
path).
You can also pass a plain room-info URL. If it answers GET <url>/.well-known/silt
with { wtEndpoint, certHash }, joinRoom dials that wtEndpoint and pins the
certHash for you; on any failure it falls through to treating the URL as the
direct WebTransport endpoint. That’s how the demo discovers a local relay’s
self-signed cert without hardcoding a hash.
JoinOptions
Section titled “JoinOptions”interface JoinOptions { id: string; certHash?: string; reconnect?: boolean; snapshotTimeoutMs?: number; connectTimeoutMs?: number; reconnectBaseMs?: number; reconnectMaxMs?: number; maxReconnectAttempts?: number;}| Option | Type | Default | Description |
|---|---|---|---|
id | string | (required) | Stable, client-supplied identity. Reconnecting = calling joinRoom again with the same id. |
certHash | string | — | SHA-256 cert hash (hex) to pin via serverCertificateHashes, for local/dev secure contexts. |
reconnect | boolean | true | Auto-reconnect with the same id on transport loss. |
snapshotTimeoutMs | number | 5000 | How long to wait for the join snapshot before joinRoom rejects. |
connectTimeoutMs | number | 8000 | Overall bound on the pre-snapshot stages (handshake, stream open, hello write). A stall in any stage rejects with the stage named, instead of hanging forever. |
reconnectBaseMs | number | 300 | First reconnect delay; doubles per attempt. |
reconnectMaxMs | number | 10000 | Ceiling on the reconnect backoff. |
maxReconnectAttempts | number | 10 | Reconnect attempts before giving up with status "failed". A silent infinite redial loop is impossible by design. |
The object returned by joinRoom.
Properties
Section titled “Properties”room.url; // readonly string — the room URL you connected toroom.id; // readonly string — your stable identityroom.peers; // Peer[] — the OTHER peers in the room, populated on joinroom.status; // ConnectionStatus — current connection lifecycle stateroom.lastError; // unknown — the last connect/transport error, never swallowedroom.peers holds the other peers and their latest presence. It’s kept in sync
as peers join, leave, and update presence. It does not include yourself.
room.status reflects the connection lifecycle ("connecting", "connected",
"reconnecting", "failed", "closed"). It transitions in step with the
"status" event below. When something fails, the
underlying error rides on room.lastError rather than being swallowed.
room.presence.set(state)
Section titled “room.presence.set(state)”room.presence.set(state: PeerState): void;Publishes your latest presence on the unreliable, latest-wins datagram lane. Call it as often as you like. If the connection isn’t ready (before connect or between reconnects) the call is a harmless no-op — the last value you set is re-published automatically on reconnect. Write failures are ignored (the lane is droppable by design).
room.presence.set({ x: 10, y: 20, heading: 90 });room.events.send(event)
Section titled “room.events.send(event)”room.events.send(event: RoomEvent): Promise<void>;Sends a discrete message on the reliable, ordered lane. Returns the write
promise. Throws "room not connected" if the room isn’t currently connected,
so a critical message can’t be silently dropped.
room.events.send({ type: "absorb", target: "bob" });room.on(type, callback)
Section titled “room.on(type, callback)”room.on(type, callback): () => void;Subscribes to a room event. Returns an unsubscribe function — call it to remove the listener.
const off = room.on("presence", (peerId, state) => { /* ... */ });off(); // stop listening| Event | Callback signature | Fires when |
|---|---|---|
"presence" | (peerId: string, state: PeerState) => void | another peer publishes presence. |
"event" | (peerId: string, event: RoomEvent) => void | another peer sends a reliable event. |
"join" | (peer: Peer) => void | a new peer joins the room. |
"leave" | (peerId: string, reason: LeaveReason) => void | a peer leaves. reason is "left" (clean) or "timeout". |
"status" | (update: StatusUpdate) => void | the connection lifecycle changes. See Connection status. |
"state" | (update: StateUpdate) => void | a compute room broadcasts authoritative state. Never fires in a relay room. |
room.close()
Section titled “room.close()”room.close(): Promise<void>;Cleanly leaves the room: sends a bye (so others see leave with reason
"left") and closes the transport. Disables auto-reconnect.
await room.close();Connection status
Section titled “Connection status”Connection failures are never swallowed. The "status" event reports every
transition in the connection lifecycle, so a stalled connect or an exhausted
reconnect surface loudly instead of hanging or redialing forever.
room.on("status", (update) => { switch (update.status) { case "reconnecting": showBanner("reconnecting…"); break; case "connected": hideBanner(); break; case "failed": showBanner("can't reach the room"); break; case "closed": showBanner("disconnected"); break; }});type ConnectionStatus = | "connecting" | "connected" | "reconnecting" | "failed" | "closed";
interface StatusUpdate { status: ConnectionStatus; error?: unknown; // the underlying failure, when there is one attempt?: number; // reconnect attempt number (1-based), on "reconnecting"/"failed"}- Auto-reconnect (the default) emits
"reconnecting"on each attempt, backing off fromreconnectBaseMstoreconnectMaxMs, and lands on"failed"aftermaxReconnectAttempts. - The initial
joinRoomstill rejects on a failed first connect (and emits"failed"); the"status"event is for everything that happens after you’re in the room.
Compute rooms
Section titled “Compute rooms”A relay room — the default — relays presence and events between peers and runs no logic of your own. A compute room additionally runs server-authoritative code and broadcasts an authoritative state, latest-wins, on its own datagram lane. The demo’s referee runs in a compute room today.
You don’t opt in from the client: the room announces its mode in the join
snapshot. In a compute room, room.presence.set becomes your input to the
authoritative simulation, and the room’s state arrives on the "state" event.
room.on("state", (update) => { render(update.state); // the authoritative state at update.tick});interface StateUpdate { tick: number; // the tick this state is authoritative at state: unknown; // your authoritative state shape}In a relay room, "state" never fires and presence.set behaves exactly as
documented above. See Relay and compute rooms.
type PeerState = unknown;type RoomEvent = unknown;type LeaveReason = "left" | "timeout";
interface Peer { id: string; state: PeerState | null; // null until the peer has sent presence}PeerState and RoomEvent are unknown — they’re whatever you put in them.
Define your own shapes and narrow on the way out:
interface Cursor { x: number; y: number; heading: number }
room.presence.set({ x, y, heading } satisfies Cursor); // your own shaperoom.on("presence", (peerId, state) => { const cursor = state as Cursor;});- Build a multiplayer game — the whole surface in one real 397-line game.
- Wire protocol — what these calls put on the wire.