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.
What you are building
Section titled “What you are building”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
tickfunction and the types it operates on. - The server owns the truth. A client cannot change state directly. It sends
a command, and your
tickchooses 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.
1. Create a project
Section titled “1. Create a project”npm create siltrun@latest my-roomcd my-roomnpm installThe 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.jsonroom.ts is the whole server. src/Game.tsx is the client that connects to it.
The rest of this guide walks those two files.
2. Write the room
Section titled “2. Write the room”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.
3. Run it
Section titled “3. Run it”One command, from your project directory:
npm run devThis boots both halves of the project:
- room:
siltrun dev room.ts. It bundles your contract, runs a determinism check, and serves room info athttp://localhost:4000. The check replays your room and verifies that replays produce identical state; if yourtickuses something nondeterministic, it fails and names the exact tick and field that drifted. The Limits page explains whattickcan 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.
4. The client
Section titled “4. The client”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:
stateis the latest authoritative state, typed by the room’s ownStatethrough that type-only import. It isundefineduntil the first broadcast arrives, then the component re-renders every tick.send(data)sends on the fast droppable lane. It arrives in yourtickaskind: "input". For a move that must never be lost, use the reliable lane:send(data, { reliable: true })arrives askind: "event".statusanderrortell you when the connection is joining, live, retrying, or gone. Render the failure states.idis the player’s identity. The scaffold keeps one per browser tab (insessionStorage), 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.
5. Deploy
Section titled “5. Deploy”Sign in once with GitHub:
npx siltrun loginThat opens your browser and stores a session, so deploys are keyed to your account. Then one command ships the room:
npx siltrun deploy room.tsName 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.
6. Share it
Section titled “6. Share it”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.