alepha@docs:~/docs/guides/server$
cat 10-rooms.md | pretty
5 min read
Last commit:

#Stateful Rooms

$websocket is a stateless, per-message handler. A real-time game server needs the opposite: in-memory state that lives across messages, and an authoritative tick loop that advances the world on the server's clock, not the client's. That is $room.

A $room is one volatile stateful room, addressed by channelPath:roomId, with a lifecycle:

  • it comes to life on the first join (running its state factory once),
  • while a socket is connected and tickHz > 0, a loop calls onTick(room, dt) every 1000/tickHz ms,
  • when the last socket leaves, onEmpty runs and the state is discarded.

The same code runs on both runtimes. On a Node VPS the room is a plain object in a Map driven by setInterval. On Cloudflare it is one Durable Object per channelPath:roomId, with its loop running inside that host. The framework owns the native host and socket lifecycle. Host recreation starts a new room engine, even when accepted sockets survive.

#An authoritative simulation room

typescript
 1import { $channel, $room } from "alepha/websocket"; 2  3class GameServer { 4  world = $channel({ 5    path: "/ws/world", 6    schema: { in: serverFrame, out: clientIntent }, 7  }); 8  9  room = $room({10    channel: this.world,11    tickHz: 20,12    state: () => new World(),13 14    onJoin: (room, conn) => room.state.spawn(conn.id),15    onMessage: (room, conn, intent) => room.state.enqueue(conn.id, intent),16    onTick: (room, dt) => {17      room.state.step(dt); // advance the authoritative sim18      for (const conn of room.connections) {19        // per-recipient AOI view20        room.send(conn.id, room.state.viewFor(conn.id));21      }22    },23    onLeave: (room, conn) => room.state.despawn(conn.id),24  });25}

Key points:

  • room.state is your world - created by the factory (which receives { roomId } and may be async), never serialized by the framework. Keep authoritative collections (players, monsters, loot) here.
  • dt in onTick is the elapsed time since the previous tick, in milliseconds.
  • room.send(id, msg) targets one connection. This is what a per-recipient area-of-interest stream needs: each client gets a different frame every tick. room.broadcast(msg, { exceptConnectionIds }) fans out to all, and room.close(id, code?, reason?) drops one connection. room.roomId, room.connections and room.size describe the room.
  • conn.data is a per-connection bag you own (last acked input seq, hero id, AOI cursor...). It is volatile and resets when the host is recreated.
  • onTick never throws out of the loop: an error is logged and the room keeps ticking. The tick loop is the speed limit; flooding messages buys no extra ticks.

#Headless coordinators and presence (methods)

Omit tickHz and the room is headless: no loop, no sockets required, addressed by id and reached through server-side methods. This models cross-room shared state and single-owner leases - reached from another room, an HTTP action, or anywhere on the server.

typescript
 1// partySchema: any z.object() / union message schema 2class Party { 3  channel = $channel({ 4    path: "/ws/party", 5    schema: { in: partySchema, out: partySchema }, 6  }); 7  8  session = $room({ 9    channel: this.channel,10    state: () => ({ switches: {} as Record<string, boolean> }),11    methods: {12      setSwitch: (room, id: string, v: boolean) => {13        room.state.switches[id] = v;14      },15      snapshot: (room) => room.state.switches,16    },17  });18 19  async flip(partyId: string, id: string) {20    await this.session.call(partyId, "setSwitch", id, true); // one room, by id21    await this.session.broadcast(partyId, { type: "state-changed" });22  }23}

On Cloudflare call() is a Durable Object RPC; on Node it is a direct in-process call. Either way the room comes to life lazily on its first call. call() resolves Promise<unknown>: the method's return type is not carried through, so cast what you read back. The primitive's broadcast(roomId, msg) reaches the sockets of one live room and is a no-op when that room holds none.

#Admission

$room takes the same admission options as $websocket (see Authentication): secure: true to require a signed-in user, maxConnectionsPerUser to cap their sockets, or an authorize hook whose returned roomId is the room the socket joins. secure and authorize together are refused at registration.

#Mapping a sharded game server onto $room

A cooperative RPG on Workers has three kinds of authoritative object. All three are one $room:

Game object $room shape addressed by
World (the 20Hz simulation of one map) tickHz: 20, state: () => new World(), onTick steps + streams AOI partyId:mapId
Session coordinator (party-wide switches/variables, chat, victory) headless, methods mutate + snapshot, broadcast pushes to rooms partyId
Presence lease (single-owner hero connection + epoch) headless, methods acquire/renew/release heroId

The pure simulation (step(), reconciliation, the wire protocol) stays in your own runtime-free package and is called from onTick/onMessage - Alepha never touches it.

#Node vs Cloudflare: what differs

Node (VPS) Cloudflare
Room instance object in a Map one Durable Object per channel:room
Tick loop setInterval setInterval inside the DO; restarted after recovery
state in memory one process, shared volatile per-engine; reset on recreation
Coordinator call() direct method call DO-to-DO RPC
Transactions full SQL D1 has no multi-statement transactions

On a single VPS a coordinator/lease is a headless room in the same process. On Cloudflare it is a Durable Object host, but its state is still ordinary in-memory state. A process restart or native host recreation loses that state. An active tick loop does not provide persistence.

Persist important changes through a repository or $actor as they happen in a method or callback. Room methods and callbacks can await actor calls; actor reducers themselves must remain pure and synchronous. onEmpty runs for the normal empty-room lifecycle; it is not a guaranteed callback before eviction, reset or process failure.

Watchdog. On Cloudflare the existing Durable Object alarm runs about every 10 seconds while the room holds sockets. After recreation it rehydrates accepted sockets the fresh engine has forgotten and restarts the tick loop. Connection attachments preserve the connection id, user id, room and query metadata; neither room.state nor conn.data is restored. Connectivity recovery is separate from application-state recovery. Headless room methods also create a fresh engine on their first call after recreation.

Actor and WebSocket hosts share runtime resolution, namespace lookup and startup coordination. They keep separate state and the existing WebSocket class, binding and channelPath:roomId identities. See namespace provisioning for declarative exports, explicit legacy history and prebuilt deployment.