#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
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.
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
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.stateis 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.dtinonTickis 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, androom.close(id, code?, reason?)drops one connection.room.roomId,room.connectionsandroom.sizedescribe the room.conn.datais a per-connection bag you own (last acked input seq, hero id, AOI cursor...). It is volatile and resets when the host is recreated.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.
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.