Build a multiplayer game

Drift is the live demo: a shared presence-relay room where every visitor is a moving dot on one canvas. Drop in, drop out, no rounds, no game-over — Silt’s pure-relay default, made alive. Open it in two tabs and the dots sync live.

The whole multiplayer surface is a handful of calls. The file is 397 lines; the rest draws dots. This page walks the part that talks to Silt — every snippet below is verbatim from examples/drift/src/main.ts.

  1. Import the client

    Vanilla TypeScript, no framework. You import joinRoom and the types you narrow against.

    import { joinRoom, type Room, type StatusUpdate } from "@siltrun/client";
  2. Join the room

    joinRoom resolves once you’re connected and the join snapshot has arrived. The bounded timeouts mean a blocked network fails loudly instead of spinning forever.

    room = await joinRoom(RELAY, {
    id: myId!,
    connectTimeoutMs: 6000, // honest port-blocked failure: bounded, no infinite spinner
    snapshotTimeoutMs: 6000,
    });
  3. Render everyone already here

    Because the snapshot has landed, room.peers is already populated. Draw the peers that arrived before you — some may not have a position yet (state is null until a peer broadcasts).

    for (const p of room.peers) {
    if (p.state) applyPresence(p.id, p.state as PresenceState);
    else ensureDot(p.id);
    }
  4. Listen — presence, join, leave, status

    Presence is the firehose: every other dot’s latest position. join/leave keep membership honest, and status surfaces the connection lifecycle so a drop is visible on screen instead of a frozen room.

    room.on("presence", (peerId, state) => {
    if (peerId === myId) return; // never let a relayed echo of ourselves fight local input
    applyPresence(peerId, state as PresenceState);
    refreshCount();
    });
    room.on("join", (peer) => {
    ensureDot(peer.id); // fades in (or revives a mid-fade dot of the same id)
    refreshCount();
    });
    room.on("leave", (peerId, _reason) => {
    // both "left" and "timeout" → the dot fades out then is reaped by the render loop
    const d = dots.get(peerId);
    if (d) d.dead = true;
    refreshCount();
    });
    room.on("status", (u: StatusUpdate) => {
    if (u.status === "reconnecting") setStatus("reconnecting…", "--warn");
    else if (u.status === "connected") { setStatus("connected", "--ok"); refreshCount(); }
    else if (u.status === "failed") { setStatus("can't reach the room (UDP may be blocked here)", "--bad"); }
    else if (u.status === "closed") setStatus("disconnected", "--muted");
    });
  5. Broadcast your own position

    Set your presence on a timer — latest-wins, droppable, so you fire it as often as you like. Here it’s ~20 Hz.

    // broadcast our presence at ~20Hz (latest-wins datagram; skip while spectating)
    setInterval(() => {
    if (spectating) return;
    room.presence.set({ x: me.x, y: me.y, heading: me.heading });
    }, 1000 / SEND_HZ);

That’s the entire multiplayer surface. No QUIC handshake, no cert pinning, no server to deploy — you join a room by URL, publish your presence, and react to everyone else’s.

Dots drifting is the easy part. What’s worth watching is the received-datagram rate: as peers join, each one adds ~20 Hz of presence to the feed. One peer is ~20 Hz; two peers is ~40 — the aggregate is the relay doing its job. That fan-out, over real WebTransport, is what Silt runs so you don’t have to.