#Stateful rooms ($room)
$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 stateful actor, addressed by channelPath:roomId, with a
lifecycle:
- it comes to life on the first join (running its
statefactory once), - while a socket is connected and
tickHz > 0, a loop callsonTick(room, dt)every1000/tickHzms, - when the last socket leaves,
onEmptyruns and the state is discarded — an empty room costs nothing.
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, the loop ticking off the DO's own clock (the
isolate stays alive while it holds a socket, so the loop keeps running; an idle
room hibernates for free). You never write a Durable Object.
#An authoritative simulation room
1class GameServer { 2 world = $channel({ 3 path: "/ws/world", 4 schema: { in: serverFrame, out: clientIntent }, 5 }); 6 7 room = $room({ 8 channel: this.world, 9 tickHz: 20,10 state: () => new World(),11 12 onJoin: (room, conn) => room.state.spawn(conn.id),13 onMessage: (room, conn, intent) => room.state.enqueue(conn.id, intent),14 onTick: (room, dt) => {15 room.state.step(dt); // advance the authoritative sim16 for (const conn of room.connections) { // per-recipient AOI view17 room.send(conn.id, room.state.viewFor(conn.id));18 }19 },20 onLeave: (room, conn) => room.state.despawn(conn.id),21 onEmpty: (room) => room.state.persist(),22 });23}
Key points:
room.stateis your world — created by the factory, never serialized by the framework. Keep authoritative collections (players, monsters, loot) here.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.conn.datais a per-connection bag you own (last acked input seq, hero id, AOI cursor…).onTicknever 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.
1class Party { 2 channel = $channel({ path: "/ws/party", schema: { in: any, out: any } }); 3 4 session = $room({ 5 channel: this.channel, 6 state: () => ({ switches: {} as Record<string, boolean> }), 7 methods: { 8 setSwitch: (room, id: string, v: boolean) => { room.state.switches[id] = v; }, 9 snapshot: (room) => room.state.switches,10 },11 });12 13 async flip(partyId: string, id: string) {14 await this.session.call(partyId, "setSwitch", id, true); // one room, by id15 await this.session.broadcast(partyId, { type: "state-changed" });16 }17}
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.
#Mapping a sharded game server onto $room
A game like lindocara — 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 (alive while a socket is) |
state in memory |
one process, shared | per-DO; survives while the room is warm |
Coordinator call() |
direct method call | DO-to-DO RPC |
| Transactions | full SQL | D1 has no multi-statement transactions |
On a single VPS you have shared memory and real timers, so a coordinator/lease is
just a headless room in the same process. On Cloudflare the same headless room is
a Durable Object — durable state that outlives an isolate must be persisted
inside a method/onEmpty (its state is in-memory and lost if the DO is
evicted while idle). An actively-ticking world room stays warm and keeps its
state for as long as it ticks.
Watchdog. A $room on Cloudflare relies on the isolate staying warm while it
holds a socket. To recover from a rare mid-connection isolate reset, a Durable
Object alarm() fires every ~10s while the room holds sockets: it re-hydrates
any hibernation socket the fresh in-memory engine has forgotten (which restarts
the tick loop) and re-arms itself. Connectivity and the loop come back
automatically; the room state does not (in-memory state cannot survive
eviction), so persist anything durable in onEmpty or a method.