Each room is one Durable Object. You write the rules in TypeScript; Tickflare keeps every player's connection, seat and view of the game in step, and saves each move before anyone sees it.
The room runs the game and sends each player what they can see. For action, co-op and fog of war.
Ada's screen
Click to steer Ada
Room arenatick 0
Ada 0 eaten
Grace 0 eaten
Grace's screen
Click to steer Grace
Click a screen to steer its player.
A tiny arena, simulated in your browser. The room runs the game ten times a second and sends each screen only what its player can see: patches out, cmdmoves in.
See the code for this demo
The real thing is about as long as the demo is small: a room with the rules, a Worker that lets players in, and a page. The room sets no tick rate, so it runs only when a move or its timer wakes it.
ts
import { Room, reject, type Player } from "tickflare";type Mark = "x" | "o";interface State { board: (Mark | null)[]; marks: Record<string, Mark>; // which player plays which mark starts: Mark; turn: Mark; winner: Mark | "draw" | null; wins: Record<Mark, number>;}export type Command = { type: "play"; cell: number };const LINES = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]];const other = (mark: Mark): Mark => (mark === "x" ? "o" : "x");// No tickRate: this room runs when a player or a timer wakes it, and sleeps the rest of the time.export class Duel extends Room<State, Command> { protected override maxPlayers = 2; protected initialState(): State { return { board: Array(9).fill(null), marks: {}, starts: "x", turn: "x", winner: null, wins: { x: 0, o: 0 } }; } // Everything a client sends is untrusted. Check it here. protected parseCommand(raw: unknown): Command { const c = raw as Partial<Command> | null; if (c?.type !== "play" || !Number.isInteger(c.cell) || c.cell! < 0 || c.cell! > 8) return reject("invalid_command"); return { type: "play", cell: c.cell! }; } protected override onJoin(player: Player): void { this.state.marks[player.id] = Object.values(this.state.marks).includes("x") ? "o" : "x"; } protected override onLeave(player: Player): void { delete this.state.marks[player.id]; } // The rules. A move that breaks them is refused, and nothing changes. protected onCommand(player: Player, command: Command): void { const state = this.state; const mark = state.marks[player.id]; if (!mark) return reject("not_playing"); if (state.winner) return reject("game_over"); if (state.turn !== mark) return reject("not_your_turn"); if (state.board[command.cell]) return reject("cell_taken"); state.board[command.cell] = mark; if (LINES.some((line) => line.every((cell) => state.board[cell] === mark))) { state.winner = mark; state.wins[mark] += 1; } else if (state.board.every(Boolean)) { state.winner = "draw"; } else { state.turn = other(mark); } // The room goes back to sleep. In three seconds the timer wakes it for the next game. if (state.winner) this.setTimer("rematch", 3000); } protected override onTimer(name: string): void { if (name !== "rematch") return; const state = this.state; state.starts = other(state.starts); state.turn = state.starts; state.board = Array(9).fill(null); state.winner = null; }}
ts
import { Accounts, routeToRoom } from "tickflare";export { Duel } from "./duel";interface Env { ROOMS: DurableObjectNamespace; DB: D1Database; JOIN_SECRET: string;}export default { async fetch(request, env): Promise<Response> { const accounts = new Accounts(env.DB, { secret: env.JOIN_SECRET }); return ( // Guest identities, sign-up and sign-in, under /api/accounts. (await accounts.route(request)) ?? // WebSockets on /rooms/<room id>, let in only with a valid identity. (await routeToRoom(request, { rooms: env.ROOMS, authenticate: accounts.authenticate })) ?? new Response("Not found", { status: 404 }) ); },} satisfies ExportedHandler<Env>;
html
<!doctype html><meta charset="utf-8" /><title>Duel</title><p id="status">Connecting…</p><div id="board" style="display: grid; grid-template-columns: repeat(3, 80px); gap: 6px"></div><script type="module"> import { AccountClient, RoomClient } from "./tickflare-client.js"; const status = document.querySelector("#status"); const accounts = new AccountClient(); // a guest until they sign up const scheme = location.protocol === "https:" ? "wss" : "ws"; const room = new RoomClient(`${scheme}://${location.host}/rooms/duel`, { token: accounts.tokenSource }); const squares = Array.from({ length: 9 }, (_, cell) => { const square = document.createElement("button"); square.style.cssText = "height: 80px; font-size: 40px"; // The room decides. A move it refuses comes back with the reason it gave. square.onclick = () => room.send({ type: "play", cell }).catch((err) => (status.textContent = `Refused: ${err.reason}`)); document.querySelector("#board").append(square); return square; }); // The whole board arrives after every move: there is nothing to keep in step by hand. room.on("state", (state) => { state.board.forEach((mark, cell) => (squares[cell].textContent = mark ?? "")); const me = state.marks[room.you]; if (state.winner) status.textContent = state.winner === "draw" ? "A draw. Next game in a moment." : `${state.winner} won. Next game in a moment.`; else status.textContent = state.turn === me ? `Your turn. You play ${me}.` : `Waiting for ${state.turn}.`; });</script>
jsonc
{ "name": "duel", "main": "src/index.ts", "compatibility_date": "2026-11-01", "assets": { "directory": "./public" }, "durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "Duel" }] }, "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Duel"] }], "d1_databases": [{ "binding": "DB", "database_name": "duel" }], // For local development only. Use `wrangler secret put JOIN_SECRET` for a deployed Worker. "vars": { "JOIN_SECRET": "local-development-only-secret" }}
The real thing is about as long as the demo is small: a room with its rules, a Worker that lets players in, and a page. No servers to run and no database to set up by hand.
ts
import { Room, reject, type Player } from "tickflare";interface Point { x: number; y: number;}interface Dot extends Point { tx: number; ty: number; score: number;}interface State { dots: Record<string, Dot>; pellets: Record<string, Point>; nextPellet: number;}export type Command = { type: "move"; x: number; y: number };const SIZE = 100;const SIGHT = 30; // how far a player can seeconst SPEED = 26; // units a secondconst near = (a: Point, b: Point, radius: number) => Math.hypot(a.x - b.x, a.y - b.y) <= radius;const round = (n: number) => Math.round(n * 10) / 10;const somewhere = (): Point => ({ x: 8 + Math.random() * (SIZE - 16), y: 8 + Math.random() * (SIZE - 16) });export class Arena extends Room<State, Command> { protected override tickRate = 10; // onTick runs ten times a second protected override maxPlayers = 8; protected override seatTimeoutMs = 60_000; // a closed tab keeps its seat for a minute protected initialState(): State { const state: State = { dots: {}, pellets: {}, nextPellet: 0 }; for (let n = 0; n < 8; n++) state.pellets[`p${state.nextPellet++}`] = somewhere(); return state; } // Everything a client sends is untrusted. Check it here. protected parseCommand(raw: unknown): Command { const c = raw as Partial<Command> | null; if (c?.type !== "move" || !Number.isFinite(c.x) || !Number.isFinite(c.y)) return reject("invalid_command"); const clamp = (n: number) => Math.max(3, Math.min(SIZE - 3, n)); return { type: "move", x: clamp(c.x!), y: clamp(c.y!) }; } protected override onJoin(player: Player): void { const at = somewhere(); this.state.dots[player.id] = { ...at, tx: at.x, ty: at.y, score: 0 }; } protected override onLeave(player: Player): void { delete this.state.dots[player.id]; } // A command only sets a target. The simulation does the moving. protected onCommand(player: Player, command: Command): void { const dot = this.state.dots[player.id]; if (!dot) return reject("not_playing"); dot.tx = command.x; dot.ty = command.y; } protected override onTick(dt: number): void { const { dots, pellets } = this.state; for (const dot of Object.values(dots)) { const distance = Math.hypot(dot.tx - dot.x, dot.ty - dot.y); const step = Math.min(distance, SPEED * dt); if (step > 0) { dot.x = round(dot.x + ((dot.tx - dot.x) / distance) * step); dot.y = round(dot.y + ((dot.ty - dot.y) / distance) * step); } for (const [id, pellet] of Object.entries(pellets)) { if (!near(dot, pellet, 4)) continue; delete pellets[id]; pellets[`p${this.state.nextPellet++}`] = somewhere(); dot.score += 1; } } } // Each player is sent only what is within sight of their own dot. protected override view(player: Player) { const me = this.state.dots[player.id]; if (!me) return { me: null, others: {}, pellets: {} }; const within = <T extends Point>(things: Record<string, T>) => Object.fromEntries(Object.entries(things).filter(([id, thing]) => id !== player.id && near(me, thing, SIGHT))); return { me, others: within(this.state.dots), pellets: within(this.state.pellets) }; }}
ts
import { Accounts, routeToRoom } from "tickflare";export { Arena } from "./arena";interface Env { ROOMS: DurableObjectNamespace; DB: D1Database; JOIN_SECRET: string;}export default { async fetch(request, env): Promise<Response> { const accounts = new Accounts(env.DB, { secret: env.JOIN_SECRET }); return ( // Guest identities, sign-up and sign-in, under /api/accounts. (await accounts.route(request)) ?? // WebSockets on /rooms/<room id>, let in only with a valid identity. (await routeToRoom(request, { rooms: env.ROOMS, authenticate: accounts.authenticate })) ?? new Response("Not found", { status: 404 }) ); },} satisfies ExportedHandler<Env>;
html
<!doctype html><meta charset="utf-8" /><title>Arena</title><canvas id="arena" width="600" height="600" style="max-width: 100%; border-radius: 14px"></canvas><script type="module"> import { AccountClient, Interpolator, RoomClient } from "./tickflare-client.js"; const canvas = document.querySelector("#arena"); const ctx = canvas.getContext("2d"); const k = canvas.width / 100; const accounts = new AccountClient(); // a guest until they sign up const scheme = location.protocol === "https:" ? "wss" : "ws"; const room = new RoomClient(`${scheme}://${location.host}/rooms/arena`, { token: accounts.tokenSource }); // Patches arrive ten times a second; the screen draws sixty. Glide between them. const glide = new Interpolator(100, ["x", "y"]); const dots = (view) => (view.me ? { ...view.others, [room.you]: view.me } : {}); room.on("state", (view) => glide.update(dots(view))); canvas.addEventListener("click", (event) => { const box = canvas.getBoundingClientRect(); const x = ((event.clientX - box.left) / box.width) * 100; const y = ((event.clientY - box.top) / box.height) * 100; room.send({ type: "move", x, y }).catch(() => {}); }); function disc(at, radius, colour) { ctx.fillStyle = colour; ctx.beginPath(); ctx.arc(at.x * k, at.y * k, radius * k, 0, Math.PI * 2); ctx.fill(); } function draw() { const view = room.state; ctx.fillStyle = "#0b101c"; ctx.fillRect(0, 0, canvas.width, canvas.height); if (view?.me) { disc(glide.at(room.you) ?? view.me, 30, "#18213a"); // what this player can see for (const pellet of Object.values(view.pellets)) disc(pellet, 1.3, "#eef1f8"); for (const id of Object.keys(dots(view))) { disc(glide.at(id) ?? dots(view)[id], 3.4, id === room.you ? "#ff4f36" : "#3d8bff"); } } requestAnimationFrame(draw); } draw();</script>
jsonc
{ "name": "arena", "main": "src/index.ts", "compatibility_date": "2026-11-01", "assets": { "directory": "./public" }, "durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "Arena" }] }, "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Arena"] }], "d1_databases": [{ "binding": "DB", "database_name": "arena" }], // For local development only. Use `wrangler secret put JOIN_SECRET` for a deployed Worker. "vars": { "JOIN_SECRET": "local-development-only-secret" }}
The room is a dozen lines, because it never runs the game. The game is sim.js, which every player's browser runs over the same turns; after a reload the client starts from the room's latest snapshot and the turns since.
ts
import { LockstepRoom, reject } from "tickflare";/** "Send my squad to this point." */export interface Order { x: number; y: number;}// The whole room. It never runs the game: ten times a second it closes a turn// holding the orders that arrived, records it, and sends every player the same turns.export class Skirmish extends LockstepRoom<Order> { protected override tickRate = 10; // turns a second protected override maxPlayers = 2; // Every player's simulation will be fed what this returns, so refuse anything odd. protected parseInput(raw: unknown): Order { const order = raw as Partial<Order> | null; const onTheMap = (n: unknown): n is number => Number.isInteger(n) && (n as number) >= 0 && (n as number) <= 1000; if (!onTheMap(order?.x) || !onTheMap(order?.y)) return reject("invalid_order"); return { x: order.x, y: order.y }; }}
js
// The game. Every player's browser runs this over the same turns, so it must// come out the same everywhere: whole numbers only, and all chance from the seed.import { seededRandom } from "./tickflare-client.js";const SPEED = 16; // per turnconst RANGE = 170;const bases = [{ x: 150, y: 500 }, { x: 850, y: 500 }];const formation = [[0, 0], [-60, -60], [60, -60], [-60, 60], [60, 60]];export function create(seed) { const units = bases.flatMap((base, side) => formation.map(([dx, dy], slot) => ({ side, slot, x: base.x + dx, y: base.y + dy, hp: 5, wait: 0, down: 0 })), ); return { units, sides: {}, target: bases.map((base) => ({ ...base })), kills: [0, 0], random: seededRandom(seed) };}export function step(sim, entries) { for (const { player, input, event } of entries) { // Joining is an entry in a turn, so every simulation seats the player at the same moment. if (event === "join" && Object.keys(sim.sides).length < 2) sim.sides[player] = Object.keys(sim.sides).length; if (input && player in sim.sides) sim.target[sim.sides[player]] = input; } for (const unit of sim.units) { if (unit.down > 0) { if (--unit.down === 0) Object.assign(unit, bases[unit.side], { hp: 5 }); continue; } const dx = sim.target[unit.side].x + formation[unit.slot][0] - unit.x; const dy = sim.target[unit.side].y + formation[unit.slot][1] - unit.y; const distance = Math.floor(Math.sqrt(dx * dx + dy * dy)); unit.x += distance <= SPEED ? dx : Math.trunc((dx * SPEED) / distance); unit.y += distance <= SPEED ? dy : Math.trunc((dy * SPEED) / distance); if (unit.wait > 0) { unit.wait -= 1; continue; } let nearest; let best = RANGE * RANGE; for (const enemy of sim.units) { const d = (enemy.x - unit.x) ** 2 + (enemy.y - unit.y) ** 2; if (enemy.side !== unit.side && enemy.down === 0 && d < best) [nearest, best] = [enemy, d]; } if (!nearest) continue; unit.wait = 4; if (sim.random() < 0.6 && --nearest.hp === 0) { nearest.down = 35; // back at its base in 3.5 seconds sim.kills[unit.side] += 1; } }}// The simulation as JSON and back. Players' clients hash what `save` returns,// so the room can tell when two of them disagree, and the room keeps a// snapshot a minute: a player who reloads starts from it, not from turn 1.export const save = (sim) => ({ ...sim, random: sim.random.state });export const load = (saved) => ({ ...saved, random: seededRandom(saved.random) });
html
<!doctype html><meta charset="utf-8" /><title>Skirmish</title><canvas id="field" width="600" height="600" style="max-width: 100%; border-radius: 14px"></canvas><script type="module"> import { AccountClient, LockstepClient } from "./tickflare-client.js"; import { create, load, save, step } from "./sim.js"; const canvas = document.querySelector("#field"); const ctx = canvas.getContext("2d"); const k = canvas.width / 1000; const accounts = new AccountClient(); // a guest until they sign up const scheme = location.protocol === "https:" ? "wss" : "ws"; // The client runs the simulation over the room's turns. After a reload it // starts from the room's latest snapshot, then carries on live. const game = new LockstepClient(`${scheme}://${location.host}/rooms/skirmish`, { token: accounts.tokenSource, create, step, save, load, onDesync: (turn) => console.error(`The simulations differ at turn ${turn}`), }); // An order is not applied here. It goes to the room and comes back in a turn, for everyone at once. canvas.addEventListener("click", (event) => { const box = canvas.getBoundingClientRect(); const x = Math.round(((event.clientX - box.left) / box.width) * 1000); const y = Math.round(((event.clientY - box.top) / box.height) * 1000); game.input({ x, y }).catch(() => {}); }); function draw() { ctx.fillStyle = "#18213a"; ctx.fillRect(0, 0, canvas.width, canvas.height); for (const unit of game.sim?.units ?? []) { if (unit.down > 0) continue; ctx.globalAlpha = 0.4 + 0.12 * unit.hp; ctx.fillStyle = unit.side === 0 ? "#ff4f36" : "#3d8bff"; ctx.beginPath(); ctx.arc(unit.x * k, unit.y * k, 27 * k, 0, Math.PI * 2); ctx.fill(); } ctx.globalAlpha = 1; requestAnimationFrame(draw); } draw();</script>
ts
import { Accounts, routeToRoom } from "tickflare";export { Skirmish } from "./skirmish";interface Env { ROOMS: DurableObjectNamespace; DB: D1Database; JOIN_SECRET: string;}export default { async fetch(request, env): Promise<Response> { const accounts = new Accounts(env.DB, { secret: env.JOIN_SECRET }); return ( // Guest identities, sign-up and sign-in, under /api/accounts. (await accounts.route(request)) ?? // WebSockets on /rooms/<room id>, let in only with a valid identity. (await routeToRoom(request, { rooms: env.ROOMS, authenticate: accounts.authenticate })) ?? new Response("Not found", { status: 404 }) ); },} satisfies ExportedHandler<Env>;
jsonc
{ "name": "skirmish", "main": "src/index.ts", "compatibility_date": "2026-11-01", "assets": { "directory": "./public" }, "durable_objects": { "bindings": [{ "name": "ROOMS", "class_name": "Skirmish" }] }, "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Skirmish"] }], "d1_databases": [{ "binding": "DB", "database_name": "skirmish" }], // For local development only. Use `wrangler secret put JOIN_SECRET` for a deployed Worker. "vars": { "JOIN_SECRET": "local-development-only-secret" }}
Every room has the same kernel: seats, timers, saving and identity work the same way. What changes is when your code runs and what goes over the wire. Here is one second of it, for one player.
Event mode
The room wakes when something happens, runs your hook, sends everyone what changed in their view and goes back to sleep.
Turn-based, cards, board games, anything played over days.
A Tickflare game is one Worker and the services it is bound to. There are no servers to size, patch or restart, and each service is billed for what it does.
ServiceWhat Tickflare uses it forWhat you pay for
WorkersThe front door
Checks who is connecting and passes their WebSocket to the right room. Also serves the accounts API and your pages.
You pay for: Per request: one to open a connection, none for the messages that follow. Pages are free.
Durable ObjectsThe rooms
Each room, and each matchmaking queue, is one. It runs your rules on a single thread, holds the players' sockets, and keeps state, seats and timers in its own SQLite.
You pay for: Time awake: about $0.0058 an hour per room. Rows saved: $1 a million. Messages from players: 20 count as one request. Nothing while asleep.
D1The records
Accounts, sessions, player data, stats and leaderboards, match history.
You pay for: Rows read and written. The plan includes 25 billion reads and 50 million writes a month.
QueuesThe courier
Carries each finished match from its room to your Worker, and keeps trying until it is stored.
You pay for: Per message. The plan includes a million operations a month.
R2The archive
Replays.
You pay for: Storage, at $0.015 per GB a month. Downloads are free.
A room costs under a cent for each hour it runs, and nothing while it sleeps: between turns, between matches, or when it is empty. Bandwidth is free. So a game played in sessions is billed by the match, not by the server: at the size of a new game that is $5 a month, or nothing. Pick an example or put in your own numbers.
Start from an example, or type your own numbers. An example fills in every setting, the ones under "More settings" too.
50 matches a day. Eight players, half an hour, an order every five seconds each.
How many matches, or play sessions, start on an average day. Each one is a room, and everything on the bill grows with this number.Sessions started
How many players share one room. More players send more messages into it, but a room costs the same to keep awake whoever is in it.In one room
How long a match lasts, from the first player in to the last one out. A room that runs is billed for every second of it. A room that sleeps is billed for its moves, however long the match takes.Start to end
What one player sends the room in a minute: moves, orders, inputs. In a room that sleeps, each one wakes it and is saved, so this is most of the bill. In a room that runs they are close to free.From each player
$5 a month on Cloudflare
$6.06 for a thousand more matches, $1.52 for a thousand player-hours. 8.3 players online on average, 25 at the peak.
The plan $5
Rooms awake $0
Saving $0
Messages $0
All of this fits inside what the plan includes, so the bill is the plan itself.
Less than the cheapest server we found ($7 a month) up to about 79 matches a day, and less than a typical one ($24) up to about 270.
Tickflare on Cloudflare$5 a month
The whole bill: rooms, storage, bandwidth, and nothing to run.
A rented server$7 to $24 a month
2 vCPU, 4 GB. You run it.
Colyseus Cloudfrom $15 a month
A managed server.
Photon Fusion$0 a month
Free up to 100 players at once, for one game.
More settings
How often a room that runs writes its state to storage: saveIntervalMs, five seconds unless you change it. Each save is one row. Saving less often costs less and loses more if the room is restarted. Rooms that sleep save each move instead.Rooms that tick: saveIntervalMs
Whether each move also sets a timer, as a turn clock or a deadline to move does. The timer is a second row written with the move, so it doubles what a move costs in a room that sleeps. Rooms that run are not affected.Rooms that sleep: a turn clock
How much busier your busiest hour is than an average one: 3 means three times as many players online. Cloudflare bills what is used, so its figure does not change. Rented servers are sized for the peak, so theirs does.Servers are sized for the peak
How many players one rented server carries at once, for the comparison with servers. It depends on your game and the machine; a few hundred to a few thousand is usual. The Cloudflare figure does not depend on it.It depends on your game
Estimates from published list prices, November 2026. The Cloudflare figure is the whole bill; the others are the server or the service alone.
The page applies a player's own command before the room has answered, and the room's state corrects it when it arrives. A refused move is taken back. Prediction
ts
const predicted = new Predictor(room, { // What the room's onCommand does, for this player. apply(state, command, you) { Object.assign(state.dots[you], { tx: command.x, ty: command.y }); }, // What its onTick does to their own dot. tick: (state, you) => walk(state.dots[you], 1 / 20),});canvas.onclick = (e) => predicted.send({ type: "move", x: e.offsetX, y: e.offsetY });draw(predicted.state);
Guests play at once and sign up later without losing anything. A forgotten password is set again with a recovery code or a link by email. Data, stats and leaderboards live in D1. Accounts
ts
// In the Workerconst accounts = new Accounts(env.DB, { secret: env.JOIN_SECRET });await accounts.addStats(winner, { wins: 1 });// In the pageconst me = new AccountClient();await me.register({ name: "Ada", password: "correct horse battery" });const top = await me.leaderboard("wins");
A queue puts waiting players together, by skill and by region if you like, keeps friends who queue together in one match and on one side, and reserves a room for them near where they are. Matchmaking
ts
export class Lobby extends Matchmaker<Env> { protected override matchSize = 2; protected override rooms = this.env.ROOMS; // Within 150 rating points, and 10 more a second waited. protected override ratingGap = 150; protected override ratingGapPerSecond = 10; protected override ratingOf(player: string) { return rating(this.env, player); }}// In the pageconst { room } = await findMatch(`wss://${location.host}/matchmaking/ranked`);
A match that counts plays on when everyone has closed the tab, through deploys too, so it has a result whoever is still watching. Playing on with nobody connected
Say what becomes of a room when its last player is gone. A room that deletes itself takes its storage with it, and leaves nothing in your account. When a room is over
ts
export class Match extends Room<State, Command> { protected override onEmpty(): void { if (this.state.over) this.deleteRoom(); else this.setTimer("abandoned", 10 * 60_000); // time to come back } protected override onTimer(name: string): void { if (name === "abandoned" && this.players.length === 0) this.deleteRoom(); }}
Both have a client: connect, reconnect, the state kept up to date, commands with the room's answer, and the browser client's interpolation and prediction. They ask for their patches in binary, a little over a third of the size, because an engine's WebSocket is not compressed. For other engines the protocol is written down, with test vectors. Godot and Unity