Skip to content

Abstract Class: LockstepRoom<Input, Env> ​

Defined in: server/lockstep.ts:69

A lockstep room. It never runs the game: it validates inputs, orders them into turns at tickRate turns a second, records every turn in which something happened, and sends all players the same stream. Players coming and going are part of that stream, so every simulation sees them at the same turn.

The recording is served at {prefix}/{roomId}/replay. Clients read it when they (re)connect, to catch up, and it doubles as the match replay.

By default the room closes the turns itself, on a timer, and so is awake and billed for as long as the match lasts. Set turnClock = "players" and it sleeps between turns instead: see turnClock.

If you override onJoin, onDisconnect or onReconnect, call super so the event still reaches the turn stream. A seat that is given up, whether the player left, timed out or was removed with removePlayer, is announced to the stream for you.

Extends ​

Type Parameters ​

Type ParameterDefault type
Input-
EnvCloudflare.Env

Constructors ​

Constructor ​

ts
new LockstepRoom<Input, Env>(ctx, env): LockstepRoom<Input, Env>;

Defined in: server/room.ts:359

Parameters ​

ParameterType
ctxDurableObjectState
envEnv

Returns ​

LockstepRoom<Input, Env>

Inherited from ​

Room.constructor

Properties ​

PropertyModifierTypeDefault valueDescriptionOverridesInherited fromDefined in
tickRateprotectednumber10Turns per second.Room.tickRate-server/lockstep.ts:75
maxInputsPerTurnprotectednumber32Inputs one player may send within a single turn. More are refused.--server/lockstep.ts:78
turnClockprotected"players" | "room""room"Who keeps time. "room": the room closes a turn every 1 / tickRate seconds on a timer of its own. It is awake for the whole match. "players": the room has no timer. LockstepClient asks it for each turn as it falls due, one player's client at a time, and the room closes as many turns as its own clock says have passed, never more: a player can neither speed the game up nor hold it back while anyone else is connected. Between requests the room is asleep, which is most of the time. Orders take effect as soon as they do with "room". Two things differ: the game stands still while nobody is connected to ask, and after a restart it skips ahead by up to saveIntervalMs of empty turns instead of going back by as much.--server/lockstep.ts:96
snapshotIntervalMsprotectednumber60_000How often the room takes a snapshot of the simulation from its players, so that someone who joins, reloads or reconnects late starts from it and not from turn 1. 0 keeps none. The room has no simulation of its own. A snapshot is what one player's client saved, with save, at a turn every client agrees on, and it is kept once the hash it came with is the one another connected player reported for that turn; a player who is alone is taken at their word. The client that loads it hashes the result again before trusting it. Clients whose simulation has no save, load and hash send none and load none, and catch up from the recording as before. Only the latest is kept. The recording is not shortened: it is still the whole match.--server/lockstep.ts:114
maxSnapshotLengthprotectednumber8_000_000The longest snapshot the room keeps, in characters of JSON. A longer one is refused.--server/lockstep.ts:117
maxPlayersprotectednumberNumber.POSITIVE_INFINITYHow many distinct players may hold a seat at once.-Room.maxPlayersserver/room.ts:150
maxMessageLengthprotectednumber...Longest client frame accepted, in characters.-Room.maxMessageLengthserver/room.ts:153
seatTimeoutMsprotectednumberNumber.POSITIVE_INFINITYHow long a disconnected player keeps their seat, in milliseconds. When it runs out, onLeave is called and the seat is given up, as if the player had left; a later connection from them is a new join. The default, Infinity, keeps seats until the player leaves or is removed, which is what a game played over days wants. A real-time room should set it, or closed tabs fill the room for good.-Room.seatTimeoutMsserver/room.ts:163
maxCommandsPerSecondprotectednumber30How many frames a second one player may send, and also how many they may send in a single burst. Frames over the limit are refused with rate_limited and never reach parseCommand. Infinity turns it off.-Room.maxCommandsPerSecondserver/room.ts:170
matchQueueprotectedQueue<MatchResult> | undefinedundefinedWhere finishMatch announces a finished match. Set it to a Queue binding: protected override matchQueue = this.env.MATCHES;-Room.matchQueueserver/room.ts:176
matchBucketprotectedR2Bucket | undefinedundefinedWhere finishMatch stores the replay it is given, if any. Set it to an R2 binding: protected override matchBucket = this.env.REPLAYS;-Room.matchBucketserver/room.ts:182
roomsprotectedRoomNamespace | undefinedundefinedWhere the other rooms are, for movePlayer and signal: the Durable Object namespace they live in, usually this room's own. protected override rooms = this.env.ROOMS;-Room.roomsserver/room.ts:189
saveIntervalMsprotectednumber5_000While the room is ticking, how often the state is written to storage, in milliseconds. It is also written whenever the simulation stops. A room that is torn down mid-simulation (a deploy, a crash) resumes from the last write.-Room.saveIntervalMsserver/room.ts:200
silentAfterMsprotectednumber120_000While the room is ticking, how long a connection may send nothing, not a command and not a keepalive, before it is taken for dead and closed with CloseCode.Silent. The player is then away, as after any other drop: onDisconnect runs and their seat timeout starts. A room ticks for as long as someone is connected, and is billed for it. A connection whose other end is gone without the room being told would keep it awake for nobody, and this is what ends that. Tickflare's clients send a keepalive every 25 seconds, so the default of two minutes never closes one that is there; a client of your own has to send ping at least this often. 0 never closes a connection for silence. A room that is not ticking never does either: it is asleep.-Room.silentAfterMsserver/room.ts:216
trackChangesprotectedbooleantrueWhether the kernel follows what each hook writes to the state, rather than turning the whole state into JSON after every hook to see what changed and to be able to undo it. On by default: a hook then costs what it touches, not what the state weighs. It means that inside a hook other than onTick, this.state and everything reached through it are proxies of the state's objects. They read and write like the objects themselves, with two exceptions: a proxy is not === to an object that did not come from the state, and structuredClone refuses one. plain(value) gives the object underneath for those. Reach the state through this.state in every hook, rather than through a reference kept from an earlier one: a change made through such a reference is not seen until the next tick or save, and is not undone if the hook is refused. Set to false to have every hook work on the plain state, at the old cost.-Room.trackChangesserver/room.ts:236

Accessors ​

state ​

Get Signature ​

ts
get protected state(): State;

Defined in: server/room.ts:242

The authoritative state. Change it only inside hooks, through this.state: see trackChanges for what a hook is handed.

Returns ​

State

Set Signature ​

ts
set protected state(value): void;

Defined in: server/room.ts:247

Replaces the whole state. Like any other change, only inside hooks.

Parameters ​
ParameterType
valueState
Returns ​

void

Inherited from ​

Room.state


players ​

Get Signature ​

ts
get protected players(): Player[];

Defined in: server/room.ts:457

Everyone with a seat, in join order.

Returns ​

Player[]

Inherited from ​

Room.players


seq ​

Get Signature ​

ts
get protected seq(): number;

Defined in: server/room.ts:464

Number of state changes so far.

Returns ​

number

Inherited from ​

Room.seq


tick ​

Get Signature ​

ts
get protected tick(): number;

Defined in: server/room.ts:469

Number of ticks run so far.

Returns ​

number

Inherited from ​

Room.tick


roomId ​

Get Signature ​

ts
get protected roomId(): string | null;

Defined in: server/room.ts:474

The id this room is reached under, once someone has connected through the gateway.

Returns ​

string | null

Inherited from ​

Room.roomId


invited ​

Get Signature ​

ts
get protected invited(): readonly string[] | null;

Defined in: server/room.ts:483

The players a matchmaker reserved this room for, or null if it was not reserved. Only they can take a seat (players handed over from another room aside), so a match can start once all of them have joined.

Returns ​

readonly string[] | null

Inherited from ​

Room.invited


teams ​

Get Signature ​

ts
get protected teams(): readonly (readonly string[])[] | null;

Defined in: server/room.ts:491

The sides the matchmaker split the invited players into, each a list of player ids, or null if it did not: see Matchmaker.teams.

Returns ​

readonly (readonly string[])[] | null

Inherited from ​

Room.teams

Methods ​

parseInput() ​

ts
abstract protected parseInput(raw): Input;

Defined in: server/lockstep.ts:145

Turn an untrusted client payload into an input. Every client will feed what this returns to its simulation, so reject anything malformed here.

Parameters ​

ParameterType
rawunknown

Returns ​

Input


onDesync() ​

ts
protected onDesync(_turn): void;

Defined in: server/lockstep.ts:148

Two players reported different hashes for turn: their simulations have diverged.

Parameters ​

ParameterType
_turnnumber

Returns ​

void


initialState() ​

ts
protected initialState(): LockstepState<Input>;

Defined in: server/lockstep.ts:150

The state of a brand new room.

Returns ​

LockstepState<Input>

Overrides ​

Room.initialState


parseCommand() ​

ts
protected parseCommand(raw): LockstepCommand<Input>;

Defined in: server/lockstep.ts:155

Turn an untrusted client payload into a command. Check everything here: raw is whatever the client sent. Throwing refuses the command.

Parameters ​

ParameterType
rawunknown

Returns ​

LockstepCommand<Input>

Overrides ​

Room.parseCommand


shouldTick() ​

ts
protected shouldTick(): boolean;

Defined in: server/lockstep.ts:264

The room keeps time only when it is asked to: with players asking, it has no simulation to wake for.

Returns ​

boolean

Overrides ​

Room.shouldTick


onCommand() ​

ts
protected onCommand(player, command): void;

Defined in: server/lockstep.ts:268

Apply a command. Call reject() to refuse it.

Parameters ​

ParameterType
playerPlayer
commandLockstepCommand<Input>

Returns ​

void

Overrides ​

Room.onCommand


onJoin() ​

ts
protected onJoin(player): void;

Defined in: server/lockstep.ts:460

A player takes a seat for the first time. Call reject() to turn them away.

Parameters ​

ParameterType
playerPlayer

Returns ​

void

Overrides ​

Room.onJoin


removePlayer() ​

ts
protected removePlayer(playerId, reason?): void;

Defined in: server/lockstep.ts:471

Every way of losing a seat ends here, so every simulation hears about it.

Parameters ​

ParameterType
playerIdstring
reason?string

Returns ​

void

Overrides ​

Room.removePlayer


onDisconnect() ​

ts
protected onDisconnect(player): void;

Defined in: server/lockstep.ts:478

A seated player's last connection closed. They keep their seat.

Parameters ​

ParameterType
playerPlayer

Returns ​

void

Overrides ​

Room.onDisconnect


onReconnect() ​

ts
protected onReconnect(player): void;

Defined in: server/lockstep.ts:482

A seated player connected again after being fully disconnected.

Parameters ​

ParameterType
playerPlayer

Returns ​

void

Overrides ​

Room.onReconnect


onTick() ​

ts
protected onTick(): void;

Defined in: server/lockstep.ts:492

Closes the turn: whatever arrived since the last one becomes this turn's entries. A turn with something in it is one row in the recording, written before any player is sent it; #resume is what lets the room itself be saved on its usual schedule instead of with every turn.

Returns ​

void

Overrides ​

Room.onTick


view() ​

ts
protected view(): LockstepView<Input>;

Defined in: server/lockstep.ts:508

What player is allowed to see. Defaults to the whole state.

Returns ​

LockstepView<Input>

Overrides ​

Room.view


onHttp() ​

ts
protected onHttp(request, player): Response | Promise<Response>;

Defined in: server/lockstep.ts:530

GET …/replay is the recording, or the end of it for a client that is catching up. POST …/snapshot is a player's client sending a snapshot. If you override this, pass on what is not yours with super.onHttp.

Parameters ​

ParameterType
requestRequest
playerPlayer

Returns ​

Response | Promise<Response>

Overrides ​

Room.onHttp


finishMatch() ​

ts
protected finishMatch(result?): void;

Defined in: server/lockstep.ts:542

Announces the end of the match and, if matchBucket is set, stores the recording as its replay.

Parameters ​

ParameterType
result?Record<string, unknown>

Returns ​

void

Overrides ​

Room.finishMatch


replay() ​

ts
protected replay(): Replay<Input>;

Defined in: server/lockstep.ts:547

The recording so far.

Returns ​

Replay<Input>


onLeave() ​

ts
protected onLeave(_player): void;

Defined in: server/room.ts:391

A player is giving up their seat: they asked to leave, or they stayed disconnected for longer than seatTimeoutMs. Tidy up after them here; they are removed when this returns. Call reject() to make them stay.

Parameters ​

ParameterType
_playerPlayer

Returns ​

void

Inherited from ​

Room.onLeave


onEmpty() ​

ts
protected onEmpty(): void;

Defined in: server/room.ts:404

The last seat was given up: nobody is in the room any more, whichever way they went (they left, stayed away too long, were removed, or moved to another room). this.players is empty. Nothing ends a room by itself, so this is where to say what becomes of it: deleteRoom(), a timer that deletes it unless somebody comes back, or nothing, for a room that waits for its next players. Not called for a room nobody ever sat in.

It runs as a hook of its own, straight after the one that emptied the room. If it fails it alone is undone: the players are still gone.

Returns ​

void

Inherited from ​

Room.onEmpty


onTimer() ​

ts
protected onTimer(_name): void;

Defined in: server/room.ts:407

A timer set with setTimer(name, ...) ran out.

Parameters ​

ParameterType
_namestring

Returns ​

void

Inherited from ​

Room.onTimer


ticksWhileEmpty() ​

ts
protected ticksWhileEmpty(): boolean;

Defined in: server/room.ts:437

Whether the simulation keeps running while nobody is connected, for a match that has to reach its end whoever is watching. shouldTick() has to agree. Checked like shouldTick(), so base it on the state.

Give such a room an end. It is awake, and billed, for every second it ticks, and nobody is there to notice one that never stops: have onTick finish or delete the room when the match is over or has run too long. After a deploy or a crash it carries on from its last save within twenty seconds.

Returns ​

boolean

Inherited from ​

Room.ticksWhileEmpty


saveNow() ​

ts
protected saveNow(): void;

Defined in: server/room.ts:502

Have what the current hook changes written to storage as soon as the hook returns, instead of at the next save interval. Only matters while the room is ticking; outside the simulation every change is written at once. Use it when the hook also writes to the room's database itself, so the two cannot come apart. Call from inside a hook.

Returns ​

void

Inherited from ​

Room.saveNow


movePlayer() ​

ts
protected movePlayer(
   playerId, 
   toRoomId, 
   carry?
): void;

Defined in: server/room.ts:545

Hand a player over to another room, for worlds that are split across rooms. Their seat here is given up; once the other room has accepted them, their connection is closed with CloseCode.Moved and the client reconnects there by itself. carry (JSON) is delivered to the other room's onArrive: put in it whatever the player takes along. Remove their part of this room's state yourself. Call from inside a hook, and set rooms.

A player who is not connected at that moment is sent on when they next connect here.

Parameters ​

ParameterType
playerIdstring
toRoomIdstring
carry?unknown

Returns ​

void

Inherited from ​

Room.movePlayer


handOver() ​

ts
protected handOver(
   roomId, 
   playerId, 
   carry
): Promise<void>;

Defined in: server/room.ts:560

Deliver a player to another room, through roomsFor. If it throws, the handover is tried again on this room's next event.

Parameters ​

ParameterType
roomIdstring
playerIdstring
carryunknown

Returns ​

Promise<void>

Inherited from ​

Room.handOver


onArrive() ​

ts
protected onArrive(player, _carry): void;

Defined in: server/room.ts:569

A player handed over by another room takes their seat. carry is what that room passed to movePlayer. Defaults to treating them as a new player.

Parameters ​

ParameterType
playerPlayer
_carryunknown

Returns ​

void

Inherited from ​

Room.onArrive


send() ​

ts
protected send(playerId, message): void;

Defined in: server/room.ts:598

Send a message to one player, outside the state: an event they should hear about once ("you were hit", a result with more in it than an ack), or a stream you keep in step yourself. The client receives it in onMessage.

Messages go out when the event that sent them is over, in the order they were sent: after the state frame that event caused, or in a ticking room after the tick's patch. A message sent while handling a command can so arrive before the next tick shows the command's effect. A message to a player who is not connected is dropped, and so is everything a hook sent if that hook is refused. Unlike the state, nothing is sent again on reconnect: send what a returning player needs from onReconnect.

Parameters ​

ParameterType
playerIdstring
messageunknown

Returns ​

void

Inherited from ​

Room.send


broadcast() ​

ts
protected broadcast(message): void;

Defined in: server/room.ts:603

send to every player who is connected.

Parameters ​

ParameterType
messageunknown

Returns ​

void

Inherited from ​

Room.broadcast


defer() ​

ts
protected defer(task): void;

Defined in: server/room.ts:616

Run task once this event is over, outside the hook: for talking to the world, which hooks cannot do because they are synchronous. Writing to D1, calling another Durable Object, fetch. Dropped if the hook is refused. Not awaited by anything and not retried: failures are logged. To change the state with what comes back, use act; to be sure it gets done across restarts, keep what is owed in the state and a timer that tries again.

Parameters ​

ParameterType
task() => unknown

Returns ​

void

Inherited from ​

Room.defer


act() ​

ts
protected act(hook): Promise<ActOutcome>;

Defined in: server/room.ts:626

Run hook like any other hook, from code that is not one: the end of a deferred task, a promise that resolved, an RPC method of your own. Its changes are committed and sent, or all taken back if it throws or calls reject(), which is what the returned outcome reports.

Parameters ​

ParameterType
hook() => void

Returns ​

Promise<ActOutcome>

Inherited from ​

Room.act


signal() ​

ts
protected signal(
   roomId, 
   data, 
   options?
): void;

Defined in: server/room.ts:643

Send a message to another room, which receives it in onSignal. It goes out once after this hook has committed. It is not saved and not retried: use it for things that are replaced by the next one anyway, such as a snapshot of what is near a border. Call from inside a hook, and set rooms.

With onlyIfWatched, a room that reports nobody connected is left alone for two seconds at a time, so an empty neighbour is not kept awake.

Parameters ​

ParameterType
roomIdstring
dataunknown
options?{ onlyIfWatched?: boolean; }
options.onlyIfWatched?boolean

Returns ​

void

Inherited from ​

Room.signal


relay() ​

ts
protected relay(roomId, data): Promise<boolean>;

Defined in: server/room.ts:656

Deliver a signal to another room, through roomsFor, resolving to whether anyone is connected there.

Parameters ​

ParameterType
roomIdstring
dataunknown

Returns ​

Promise<boolean>

Inherited from ​

Room.relay


onSignal() ​

ts
protected onSignal(_fromRoomId, _data): void;

Defined in: server/room.ts:662

Another room sent this one a message with signal.

Parameters ​

ParameterType
_fromRoomIdstring
_dataunknown

Returns ​

void

Inherited from ​

Room.onSignal


roomsFor() ​

ts
protected roomsFor(_roomId): RoomNamespace | undefined;

Defined in: server/room.ts:686

The namespace the room called roomId lives in. Defaults to rooms. Override it when handovers and signals go to rooms of more than one class, e.g. by the id's prefix.

Parameters ​

ParameterType
_roomIdstring

Returns ​

RoomNamespace | undefined

Inherited from ​

Room.roomsFor


setTimer() ​

ts
protected setTimer(name, delayMs): number;

Defined in: server/room.ts:726

Run onTimer(name) once, delayMs from now. A timer with the same name is replaced. Returns the time it fires in epoch milliseconds, which you can put in the state to show a countdown. Call from inside a hook.

Timers are saved with the room, so they survive hibernation and restarts.

Parameters ​

ParameterType
namestring
delayMsnumber

Returns ​

number

Inherited from ​

Room.setTimer


deleteRoom() ​

ts
protected deleteRoom(): void;

Defined in: server/room.ts:752

End this room for good. Shortly after the current event, 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 ceases to exist.

What the room still owes is delivered first: a finished match's result, a player on their way to another room. Until it is, the room stays. Work passed to defer() that is still running is given ten seconds to finish.

The room id is not used up. Someone who comes to it later finds a new, empty room. Call from inside a hook.

Returns ​

void

Inherited from ​

Room.deleteRoom


clearTimer() ​

ts
protected clearTimer(name): void;

Defined in: server/room.ts:757

Cancel a timer. Does nothing if it is not set. Call from inside a hook.

Parameters ​

ParameterType
namestring

Returns ​

void

Inherited from ​

Room.clearTimer


fetch() ​

ts
fetch(request): Promise<Response>;

Defined in: server/room.ts:775

Durable Object entry point for connections and HTTP requests from the gateway. Tickflare's: override onHttp instead.

Parameters ​

ParameterType
requestRequest

Returns ​

Promise<Response>

Inherited from ​

Room.fetch


webSocketMessage() ​

ts
webSocketMessage(ws, message): Promise<void>;

Defined in: server/room.ts:810

Durable Object entry point for incoming frames. Tickflare's: override parseCommand and onCommand instead.

Parameters ​

ParameterType
wsWebSocket
messagestring | ArrayBuffer

Returns ​

Promise<void>

Inherited from ​

Room.webSocketMessage


webSocketClose() ​

ts
webSocketClose(
   ws, 
   code, 
   reason
): Promise<void>;

Defined in: server/room.ts:818

Durable Object entry point for closed connections. Tickflare's: override onDisconnect instead.

Parameters ​

ParameterType
wsWebSocket
codenumber
reasonstring

Returns ​

Promise<void>

Inherited from ​

Room.webSocketClose


webSocketError() ​

ts
webSocketError(ws): Promise<void>;

Defined in: server/room.ts:831

Durable Object entry point for broken connections. Tickflare's: override onDisconnect instead.

Parameters ​

ParameterType
wsWebSocket

Returns ​

Promise<void>

Inherited from ​

Room.webSocketError


alarm() ​

ts
alarm(): Promise<void>;

Defined in: server/room.ts:838

Durable Object entry point for the alarm that drives timers and retries. Tickflare's: override onTimer instead.

Returns ​

Promise<void>

Inherited from ​

Room.alarm

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