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.

import { useRoom } from "@siltrun/react";
const { state, send, status, error } = useRoom<State>(url, { id });
FieldTypeMeaning
stateState | undefinedLatest 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?) => voidsend(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.
statusConnectionStatus"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).
errorunknownThe 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";

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.

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.

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 });

@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-wins
room.events.send({ type: "move", to: "e4" }); // reliable lane, ordered
room.peers; // [{ id, state }], populated on join
room.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.