Connecting a client
The Quickstart shows the happy path: one useRoom call, a
state to render, a send for intent. This page covers the rest of the
surface, the identity rules that matter in production, and how to connect
without React.
The hook
Section titled “The hook”import { useRoom } from "@siltrun/react";
const { state, send, status, error } = useRoom<State>(url, { id });| Field | Type | Meaning |
|---|---|---|
state | State | undefined | Latest authoritative state. undefined until the first broadcast arrives, then the component re-renders on every tick. The type parameter is your declared shape; the wire is untyped JSON, so it is a cast, not a runtime check. |
send | (data, opts?) => void | send(data) uses the droppable datagram lane and arrives in tick as kind: "input", latest value wins per tick. send(data, { reliable: true }) uses the ordered reliable lane and arrives as kind: "event", every one delivered. Sends before the connection is live are dropped. |
status | ConnectionStatus | "connecting" while joining, "connected" once live, "reconnecting" during automatic retries, then either "failed" (terminal: join rejected or the retry cap was hit) or "closed" (deliberate close). |
error | unknown | The underlying failure when status is "failed". |
Render the failure states. A component that only handles state shows an
eternal spinner when the room is unreachable.
Types are importable:
import type { ConnectionStatus, SendOptions, RoomHandle } from "@siltrun/react";Which lane for which message
Section titled “Which lane for which message”send(data) is for signals where only the latest value matters: pointer
position, analog stick state, a held key. The server samples at most one
datagram per player per tick, and dropped ones are silently superseded. Model
these commands as state you store, like a movement target, rather than one-shot
pulses.
send(data, { reliable: true }) is for messages that must not be lost: a chess
move, a chat line, a purchase. For a turn-based game, moves belong on this lane.
A dropped datagram move would vanish without a trace; a reliable event cannot.
The two-lane model covers the same split at the transport level.
Identity
Section titled “Identity”The id you pass is the player’s stable identity, and it follows three rules:
- Captured once at mount. An inline
crypto.randomUUID()still means one identity per mounted component. Passing a new value on re-render changes nothing. - Reconnecting with the same id resumes the same player. The server replaces the session in place, so a refresh or a dropped connection does not create a ghost player.
- Never run two live clients with the same id. They supersede each other, and automatic reconnect makes them fight over the session. Give each browser tab or client instance its own id. Sequential drop-then-rejoin with the same id is the supported reconnect story.
Connections dedupe by url + id at module scope: two components pointed at the
same room share one connection, and the last unmount closes it. StrictMode is
safe.
Sharing types with the room
Section titled “Sharing types with the room”The contract and the client can share types with a type-only import of server code. It is erased at build, so nothing server-side leaks into your bundle:
import type { State } from "../room";
const { state } = useRoom<State>(url, { id });Headless and non-React clients
Section titled “Headless and non-React clients”@siltrun/react is a thin wrapper over @siltrun/client, the transport layer.
For bots, test harnesses, and non-React UIs, use it directly:
import { joinRoom } from "@siltrun/client";
const room = await joinRoom(url, { id: myPlayerId });
room.presence.set({ x, y }); // datagram lane, latest-winsroom.events.send({ type: "move", to: "e4" }); // reliable lane, orderedroom.peers; // [{ id, state }], populated on joinroom.on("state", (state) => {});room.on("event", (peerId, event) => {});room.on("join", (peer) => {});room.on("leave", (peerId, reason) => {}); // reason: "left" | "timeout"room.close();The API reference documents that surface in full.