Quickstart

This guide takes you from nothing to a running multiplayer room. You write one file, run it, and point a client at it.

You need Node 18 or higher and a Chromium-based browser (Chrome or Edge). Silt rooms use WebTransport, which Chromium supports directly. Safari works too, through a certificate step the dev server handles for you.

A room is one TypeScript file. It default-exports a tick(state, inputs) function that the server runs 60 times a second. The server owns the state. Clients send intent, your tick decides what the state becomes, and the server broadcasts that state back to every client each tick.

Three ideas carry the whole model:

  • One file is the room. No routes, no handlers, no database. A tick function and the types it operates on.
  • The server owns the truth. A client cannot change state directly. It sends a command, and your tick chooses what to do with it.
  • Two lanes carry intent. A fast droppable lane for high-frequency signals like pointer position, and a reliable ordered lane for moves that must not be lost.
Terminal window
npm create siltrun@latest my-room
cd my-room
npm install

The project directory is required — npm create siltrun@latest on its own prints usage and exits.

This scaffolds a room file (room.ts) and a small client wired to it. The layout:

my-room/
room.ts your contract: tick(state, inputs), runs server-side
src/
Game.tsx the client, built on useRoom
main.tsx
index.html
vite.config.ts
tsconfig.json
package.json

room.ts is the whole server. src/Game.tsx is the client that connects to it. The rest of this guide walks those two files.

Open room.ts. A room file has one runtime export, the default, and at most one import, a type-only import that is erased at build. The scaffold ships this room, where each connected player controls a dot that eases toward wherever they point:

// room.ts — your authoritative room contract. This file runs SERVER-SIDE at 60Hz;
// browsers submit intent, this tick() decides truth, and every client receives the
// full state each tick. Edit it while `npm run dev` is running — it hot-reloads.
import type { Room } from "@siltrun/room";
type Ship = { x: number; y: number; tx?: number; ty?: number };
export type State = { ships: Record<string, Ship> };
export type Cmd = { x: number; y: number };
const clamp = (v: number) => Math.max(0, Math.min(520, v));
export default {
tick(state = { ships: {} }, inputs) {
for (const ev of inputs) {
if (ev.kind === "leave") { delete state.ships[ev.id]; continue; }
if (ev.kind === "join") { state.ships[ev.id] = { x: 260, y: 140 }; continue; }
if (ev.kind === "input") {
const s = state.ships[ev.from];
if (s) { s.tx = clamp(ev.data.x); s.ty = clamp(ev.data.y); } // the server clamps intent
}
}
for (const id in state.ships) {
const s = state.ships[id];
if (s.tx == null || s.ty == null) continue;
s.x += (s.tx - s.x) * 0.1;
s.y += (s.ty - s.y) * 0.1;
}
return state;
},
} satisfies Room<State, Cmd>;

Read inputs as everything that happened since the last tick, in one ordered array: players joining, players leaving, and the commands they sent. You return the next state. The server clones the state for you each tick, so you can mutate the copy and return it.

One rule to notice now: the server clamps the incoming coordinates before storing them. Client input is untyped JSON on the wire. Your tick is the only thing that decides what is valid, so treat ev.data as untrusted.

One command, from your project directory:

Terminal window
npm run dev

This boots both halves of the project:

  • room: siltrun dev room.ts. It bundles your contract, runs a determinism check, and serves room info at http://localhost:4000. The check replays your room and verifies that replays produce identical state; if your tick uses something nondeterministic, it fails and names the exact tick and field that drifted. The Limits page explains what tick can and cannot use.
  • web: the Vite dev server for the client, at http://localhost:5173.

Open http://localhost:5173 in two browser windows side by side. Tap or click the field in one window. Each window is its own player, and both windows render the same server-owned state.

Leave it running. Saving room.ts reloads the room, with the determinism check run again on each save; room state resets on reload in dev. If you ever want the room half alone, for example to develop a client of your own against it, npx siltrun dev room.ts runs just the server.

The client is one hook plus a renderer. The scaffold’s src/Game.tsx calls useRoom for state and draws the result on a @siltrun/stage canvas:

import { useEffect, useRef } from "react";
import { useRoom } from "@siltrun/react";
import { createStage, createCamera, type StageHandle } from "@siltrun/stage";
import { tapBoard } from "@siltrun/stage/input";
import { Graphics } from "pixi.js";
// The server and client share types with a type-only import — erased at build, so
// nothing server-side leaks into the bundle. One project, one language, one seam.
import type { State } from "../room";
// A stable player identity, kept for this browser tab.
const PLAYER_ID = sessionStorage.getItem("player-id") ?? crypto.randomUUID();
sessionStorage.setItem("player-id", PLAYER_ID);
// The playfield in world units — the same 0..520 space the server clamps intent to.
const FIELD = { x: 0, y: 0, w: 520, h: 280 };
const INK = 0x0b0b0d, BONE = 0xeae7de, SILT = 0x7e837a;
export function Game() {
const { state, send, status, error } = useRoom<State>("http://localhost:4000", { id: PLAYER_ID });
// React owns the DOM chrome; the stage redraws the world each frame from this ref.
const stateRef = useRef(state);
stateRef.current = state;
const hostRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let stage: StageHandle | undefined;
let cancelled = false;
// createStage is awaited INSIDE the effect — never top-level. (A top-level await
// deadlocks a bundled build; see the @siltrun/stage README.)
createStage(hostRef.current!, { background: INK }).then((s) => {
if (cancelled) return s.dispose();
stage = s;
// One camera call: frame the field on any screen, refit on rotate/resize.
const cam = createCamera(s);
const fit = () => cam.fitRect(FIELD, { pad: 24 });
fit(); s.onResize(fit);
// One input primitive: tap (or click) → world point → intent to the server.
tapBoard(s.app.canvas, {
map: (sx, sy) => cam.toWorld(sx, sy),
onTap: (p) => send({ x: p.x, y: p.y }),
});
// Draw: vector shapes redrawn from authoritative state every frame.
const gfx = s.world.addChild(new Graphics());
s.app.ticker.add((tk) => {
cam.update(tk.deltaMS / 1000);
gfx.clear();
for (const [id, ship] of Object.entries(stateRef.current?.ships ?? {})) {
gfx.circle(ship.x, ship.y, 8).fill(id === PLAYER_ID ? BONE : SILT);
}
});
});
return () => { cancelled = true; stage?.dispose(); }; // dispose tears down ticker + canvas + inputs
}, [send]);
return <div ref={hostRef} style={{ position: "absolute", inset: 0 }} />;
}

The scaffolded file also renders a status hint line (joining, reconnecting, or the connection error) over the canvas; that part is trimmed here for length.

What the hook gives you:

  • state is the latest authoritative state, typed by the room’s own State through that type-only import. It is undefined until the first broadcast arrives, then the component re-renders every tick.
  • send(data) sends on the fast droppable lane. It arrives in your tick as kind: "input". For a move that must never be lost, use the reliable lane: send(data, { reliable: true }) arrives as kind: "event".
  • status and error tell you when the connection is joining, live, retrying, or gone. Render the failure states.
  • id is the player’s identity. The scaffold keeps one per browser tab (in sessionStorage), so a refresh resumes the same player and every new window is a new player.

The URL is the room info endpoint from step 3. The client fetches it, discovers the transport endpoint and the dev certificate, and connects. You never handle certificates yourself.

See Connecting a client for the full hook surface, identity rules, and headless (non-React) clients.

Sign in once with GitHub:

Terminal window
npx siltrun login

That opens your browser and stores a session, so deploys are keyed to your account. Then one command ships the room:

Terminal window
npx siltrun deploy room.ts

Name the room with --room <name> (12 characters max, since deployed room names are capped); without it, the name derives from your project directory.

The same determinism check gates the deploy: if it fails locally, nothing ships. When it succeeds, the command prints two URLs:

  • your room endpoint, for client code to connect to
  • a check page, a plain web page that connects to the room and shows it live

Point your client at the deployed room by changing the useRoom URL from http://localhost:4000 to the printed room endpoint, then build and host the client like any static site (npm run build).

Your room also shows up at console.silt.run. Sign in with the same GitHub account to see every room you own, its status, and its live state.

See Deploying for room naming, redeploys, and what persists.

One thing to know before you paste a link to a friend: the room endpoint is a WebTransport address. It will not open in a browser tab, and curl gets nothing from it. Both speak plain HTTP when you hand them a URL, and the room does not. Seeing a blank tab there does not mean your room is down.

The link you share is the check page. Anyone who opens it lands in your live room. Once your own client is hosted, share that instead: it is the game.