@siltrun/client
v0.4.0
Published
Silt transport-core client SDK — two lanes (presence/events) over one WebTransport connection, plus the browser-clean predict core (@siltrun/client/predict).
Maintainers
Readme
@siltrun/client
The browser SDK for Silt rooms. One WebTransport connection carries two lanes: a droppable datagram lane for high-frequency signals like pointer position, and a reliable ordered lane for actions that must not be lost. A plain WebSocket cannot give you the first one, because it head-of-line-blocks under packet loss.
The same client talks to both kinds of room. In a relay room Silt just carries
packets and each peer is its own authority. In a compute room your own
tick(state, inputs) runs server-side at 60Hz and broadcasts authoritative state.
No framework dependency. If you are using React, @siltrun/react wraps this
connection in a useRoom hook.
Install
npm i @siltrun/clientJoin a room
import { joinRoom } from "@siltrun/client";
const room = await joinRoom("http://localhost:4000", { id: crypto.randomUUID() });
// droppable lane: latest-wins, safe to call every frame
addEventListener("pointermove", (e) => {
room.presence.set({ x: e.clientX, y: e.clientY });
});
// reliable lane: ordered, never dropped
room.events.send({ type: "chat", text: "hello" });
room.on("presence", (peerId, state) => { /* another peer's latest presence */ });
room.on("event", (peerId, event) => { /* a reliable message from a peer */ });
room.on("join", (peer) => { /* peer.id, peer.state */ });
room.on("leave", (peerId, reason) => { /* "left" | "timeout" */ });
room.on("status", ({ status, error }) => { /* see below */ });
await room.close();room.peers holds the other peers and their latest presence, populated at join.
Every on(...) call returns a function that unsubscribes it.
The URL can point straight at a WebTransport endpoint, or at a room-info URL that
answers GET <url>/.well-known/silt. The latter is what siltrun dev serves on
port 4000, and it is how the client discovers the endpoint and pins the local
development certificate for you.
Compute rooms
When the room runs your own contract, the server broadcasts state every tick:
room.on("state", ({ tick, state, appliedSeq }) => {
render(state as GameState);
});state is untyped on the wire, so the type parameter is your assertion about the
shape, not a runtime check.
appliedSeq is the last input sequence number the server has applied for this
peer. It is the ack that client-side reconciliation lines up against. In a compute
room presence.set returns the { seq, clientTick } stamp for the input it just
sent, so you can match your local history to that ack. In a relay room there is no
envelope and it returns undefined.
Lockstep rooms
When the room declares inputs: { schedule: "tick", delay: N }, the server assigns every
input a tick and broadcasts the ordered per-tick log to every peer, so a deterministic
simulation can run identically on every client:
if (room.lockstep) { // { schedule, delay } | null
const { seq, sent } = room.inputs.send({ laser: true });
sent.catch((e) => engine.onInputLost(seq, e));
room.on("log", ({ tick, inputs }) => { // contiguous, one callback per tick
for (const { from, seq, data } of inputs) engine.apply(from, seq, data);
});
room.on("gap", ({ expected, got, reason }) => {
// "reconnect" — expected after a transport drop; the log resumes at the room's
// current tick, so re-seed. "protocol" — a gap within one connection; treat the
// simulation as unsound.
engine.reseed();
});
}inputs.send is the reliable lane: it throws synchronously if the room is not connected,
and hands back sent so an async write failure is observable too — a lost scheduled input
is a hole in a replicated timeline that nothing recovers. You learn the tick an input
landed on by finding (from === your id, seq) in a "log" frame.
presence.set throws in a lockstep room — the datagram input lane is disabled there.
Calling inputs.send in a room that did not opt in throws too.
Connection status
room.on("status", ...) reports connecting, connected, reconnecting,
failed, or closed, with the underlying error when there is one. Failures are
never swallowed.
Reconnect is automatic and reuses the same id. On transport loss the client
redials with exponential backoff and gives up with status failed after a bounded
number of attempts, so a silent infinite redial loop is not possible. Options on
joinRoom control the identity, certificate pinning, the connect and snapshot
timeouts, and the backoff shape.
Subpath exports
@siltrun/client/predictruns your room contract optimistically in a Web Worker and reconciles it against the server's ack, so local input feels immediate while the server still owns the truth.@siltrun/client/predict/workeris the worker entry that pairs with it.
Docs
Full guides and API reference: https://silt.run/docs/
