Skip to content

Rooms and hooks ​

A room is one Durable Object: one match, one table, one lobby. You write it as a class that extends Room<State, Command, Env> and override the hooks you need. The room calls them when something happens and takes care of everything around them.

ts
import { Room, reject, type Player } from "tickflare";

export class Duel extends Room<State, Command, Env> {
  protected override maxPlayers = 2;

  protected initialState(): State { /* ... */ }
  protected parseCommand(raw: unknown): Command { /* ... */ }
  protected onCommand(player: Player, command: Command): void { /* ... */ }
}

The three type parameters are your state, the commands players send, and your Worker's bindings. Env defaults to Cloudflare.Env, which wrangler types fills in for you.

What happens on an event ​

In event mode, the default, a room has no loop. It wakes when something happens, handles it and goes back to sleep:

  1. Something happens: a player connects, sends a command, drops or leaves, or a timer runs out.
  2. The room calls the matching hook. The hook changes this.state in place.
  3. If the hook calls reject() or throws, everything it did is undone and the event ends there.
  4. Otherwise the room writes the new state to its storage.
  5. Then it sends every connected player what changed in their view of the state, and answers the player who sent the command.

Between events the room hibernates. The WebSockets stay open and Cloudflare stops billing for the object's time, so a game that waits a day between moves costs almost nothing while it waits.

Hooks ​

Three hooks are required. The rest have defaults.

HookCalled whenNotes
initialState()The room is used for the first timeRequired
parseCommand(raw)A command arrivesRequired. Turn untrusted input into a Command; throwing refuses it
onCommand(player, command)After parseCommandRequired. Change this.state; call reject(reason) to refuse
onJoin(player)A player takes a seat for the first timereject(reason) turns them away
onDisconnect(player)A seated player's last connection closesThey keep their seat
onReconnect(player)A seated player comes back
onLeave(player)A player asks to leave, or stayed away longer than seatTimeoutMsTidy up; reject(reason) makes them stay
onEmpty()The last seat is given up, however the players wentSay what becomes of the room. Not called for a room nobody ever sat in
onTimer(name)A timer set with setTimer runs out
onTick(dt)tickRate times a second, in state syncdt is always 1 / tickRate seconds
shouldTick()After every event and every tickReturn false to pause the simulation
view(player)Before the state is sent to a playerWhat that player may see. Defaults to everything
onHttp(request, player)An HTTP request to /rooms/<id>/...For reading, e.g. a scoreboard. Defaults to 404
onArrive(player, carry)Another room handed a player overDefaults to onJoin
onSignal(fromRoomId, data)Another room sent this one a signal

Every hook receives a Player: { id, connected, name? }. Players and seats explains the seat lifecycle in detail.

Options ​

Options are fields you override:

FieldDefaultMeaning
maxPlayersInfinityDistinct players who may hold a seat at once
seatTimeoutMsInfinityHow long a disconnected player keeps their seat
maxCommandsPerSecond30Frames one player may send a second, and in one burst
maxMessageLength16384Longest frame accepted, in characters
tickRate0Ticks a second. 0 is event mode
saveIntervalMs5000While ticking, how often the state is written
trackChangestrueFollow what each hook writes, instead of comparing the whole state after it. See The state inside a hook
matchQueuenoneA Queue binding for finished matches
matchBucketnoneAn R2 binding for replays
roomsnoneThe namespace other rooms live in, for handover and signals

Bindings come from this.env, which is ready when field initialisers run:

ts
protected override matchQueue = this.env.MATCHES;

Inside a hook ​

These are available on this while a hook runs:

MemberWhat it does
stateThe authoritative state. Change it in place
playersEveryone with a seat, in join order
roomIdThe id this room is reached under
seq, tickChanges and ticks so far
setTimer(name, ms) / clearTimer(name)Timers that survive restarts
send(playerId, message) / broadcast(message)Messages outside the state
defer(task) / act(hook)Async work after the hook
removePlayer(playerId, reason?)Take a seat away
finishMatch(result, replay?)Announce a finished match
movePlayer(playerId, roomId, carry?) / signal(roomId, data)Talk to other rooms
saveNow()While ticking, write this hook's changes at once
deleteRoom()End the room and delete what it stored

The state inside a hook ​

A hook should cost what it does, not what the state weighs. So the room does not copy the state before a hook in order to undo it, or go through all of it afterwards to see what changed. It follows the hook's writes: inside a hook, this.state and everything you reach through it are stand-ins (JavaScript proxies) for the state's objects, which note each change as it is made. They read and write like the objects themselves:

ts
const unit = this.state.units[command.unit];
unit.target = command.target;          // noted, and undone if the hook is refused
this.state.units[id] = { ...unit, hp: 10 };
this.state.order.push(id);

Three things are different from plain objects:

  • A stand-in is not === to an object that did not come from the state. Two reads of the same unit give the same stand-in, so comparing state with state works. An object you made yourself and then put into the state is the exception: read back, it is its stand-in. includes, indexOf and lastIndexOf see through this; a comparison of your own does not, so compare ids.
  • structuredClone refuses a stand-in, and so does anything that clones what it is handed: a Queue, a call to another Durable Object. plain(value), exported by tickflare, gives the object underneath: structuredClone(plain(this.state.board)). Read from what it gives you; a change made through it is not followed. It is also the faster way to read a large part of the state in one go, to hash it for instance.
  • Go through this.state in every hook. Do not keep an object of the state in a field of the room and change it in a later hook. Such a change is not followed either: it is not sent until the next tick or save, and it stays if the hook is refused.

onTick is the exception. A simulation reads far more than it writes, and reads through stand-ins are slower, so onTick gets the state itself and the room compares the whole of it once the tick is done. view(), shouldTick() and onHttp get the state itself too.

If your hooks need plain objects everywhere, set trackChanges = false. Every hook then works on the state itself, and each one costs a pass over the whole state.

Rules for hooks ​

  • Hooks are synchronous. A hook that returns a promise is refused with internal_error, because the room can't save or roll back work that finishes later. Use defer for anything that waits.
  • The state must be JSON-serialisable. It is stored and sent as JSON.
  • Change the state only inside hooks, through this.state. What onHttp changes is discarded, and so is anything a refused hook's leftover promise changes later. See The state inside a hook.
  • Identity is decided at the gateway. Rooms trust the player id your authenticate function returns (Identity and join tokens).

What the room does for you ​

Rollback. If a hook calls reject() or throws, everything it did is undone: changes to this.state, timers it set or cleared, players it removed, messages it sent, and anything it wrote to the room's own SQLite database. The same happens if the result can't be saved, for instance a match result that isn't JSON.

Persistence. Every accepted change is written to the room's storage before anyone hears about it. A room that hibernates, is evicted or is redeployed picks up where it left off, timers included.

Ordering. Each change gets the next seq. The sender's acknowledgement arrives after the state the command caused, so when room.send() resolves on the client, room.state already shows the result. In a room that is ticking that is with the next tick.

One connection per player. A newer connection for the same player closes the older one with CloseCode.Replaced.

Rate limiting. Each player may send maxCommandsPerSecond frames a second, with a burst of the same size. Frames over the limit are refused with rate_limited before they reach your code. The allowance belongs to the player, so reconnecting does not refill it.

When a room is over ​

A room begins to exist with the first player who is seated in it. Before that it stores nothing: a refused join, a request for a room that was never used or a mistyped room id leaves nothing behind.

From then on it stays until you end it. Nothing ends a room by itself, not an empty lobby, not a finished match, so decide when yours is over and call this.deleteRoom(). The usual place is onEmpty(), which runs when the last seat is given up, whether its player left, stayed away longer than seatTimeoutMs, was removed or moved to another room:

ts
protected override onEmpty(): void {
  this.deleteRoom();
}

onEmpty() runs as a hook of its own, straight after the one that emptied the room, with this.players empty. A match worth coming back to can set a timer there instead and delete itself when it fires; Rooms nobody is in shows one.

Shortly after the hook, everyone still connected is disconnected with CloseCode.Removed and the reason "room_deleted", and everything the room stored is deleted: its state, seats and timers, a lockstep recording, and any tables of your own. With nothing stored, the Durable Object itself is gone from your account.

  • What the room owes goes out first. A match announced with finishMatch, recording included, and a player on their way to another room are delivered before anything is deleted. If a delivery fails the room stays and tries again every 30 seconds.
  • Deferred work gets to finish. A task passed to defer() that is still running, telling a lobby list that the room is gone for instance, is given ten seconds.
  • Your logs show it. The room resets itself with ctx.abort(), which Cloudflare records as an uncaught error reading "deleteRoom(): this room deleted itself and its storage. Not a failure."
  • The room id is free again. Whoever comes to it later finds a new, empty room.
  • It is undone with the hook, like everything else, if the hook is refused.

Rooms that are never deleted cost nothing while nothing wakes them, and a room's storage is billed by size, which for most games is small. Delete them anyway: a list of rooms that are over is hard to tell from a list of rooms that are stuck.

Reaching a room ​

Rooms are named. routeToRoom sends /rooms/<id> to the object called <id>, creating it on first use. Pick ids that mean something to your game:

  • a lobby everyone shares: /rooms/lobby
  • a match a matchmaker set up: /rooms/m-7f3a… (Matchmaking)
  • a cell of a big world: /rooms/overworld_3_-2 (Worlds across rooms)

A room id is 1 to 64 letters, digits, dashes and underscores; the gateway answers 400 to anything else under /rooms/.

MIT licence. Tickflare is an independent project and is not affiliated with Cloudflare.