Turn-Based Game
Build a turn-based game such as correspondence chess, where a match Actor validates every move and keeps the board between turns.
What we’ll build:
- A
turnBasedMatchmakerActor that creates private games with invite codes and pairs players from a public queue. - A
turnBasedMatchActor per match that owns the board, whose turn it is, and the result. - Move validation on the server, so a client can only play its own symbol, on its own turn, in an empty cell.
- A browser client that creates or joins a game and plays it in realtime.
Install
npm add rivetkit@3.0.0-preview.1
Create the matchmaker
One turnBasedMatchmaker Actor, keyed ["main"], hands out matches. It supports two ways in: a private game shared by invite code, and a public queue that pairs the first two players who ask:
/*
This matchmaker supports invite and queued public matchmaking flows.
1. createGame creates a private match with the first player as X.
2. joinByCode joins an existing private match by invite code as O.
3. queueForMatch action enqueues players into the public pool.
4. When two players are queued, the matchmaker creates a match and stores public assignments without invite codes.
5. getAssignment lets clients poll for their match assignment.
6. onDisconnect unqueues waiting players so stale queue entries do not remain.
*/
import { type ActorContextOf, actor, event, queue, UserError } from "rivetkit";
import { db, type RawAccess } from "rivetkit/db";
import type { registry } from "../index.ts";
import { generateInviteCode } from "./config.ts";
export interface TurnBasedAssignment {
matchId: string;
playerId: string;
inviteCode?: string;
connId: string | null;
}
type QueuePlayerRow = {
player_id: string;
player_name: string;
queued_at: number;
conn_id: string | null;
};
export const turnBasedMatchmaker = actor({
options: { name: "Turn-Based - Matchmaker", icon: "chess-board" },
db: db({
onMigrate: migrateTables,
}),
queues: {
createGame: queue<
{ playerName: string },
{ matchId: string; playerId: string; inviteCode: string }
>(),
joinByCode: queue<
{ inviteCode: string; playerName: string },
{ matchId: string; playerId: string }
>(),
queueForMatch: queue<{
playerId: string;
playerName: string;
connId: string;
}>(),
unqueueForMatch: queue<{ connId: string }>(),
closeMatch: queue<{ matchId: string }>(),
},
events: {
assignmentReady: event<TurnBasedAssignment>(),
},
actions: {
queueForMatch: async (c, { playerName }: { playerName: string }) => {
const playerId = crypto.randomUUID();
await c.queue.send("queueForMatch", {
playerId,
playerName,
connId: c.conn.id,
});
return { playerId };
},
getAssignment: async (c, { playerId }: { playerId: string }) => {
const rows = await c.db.execute<{
match_id: string;
player_id: string;
invite_code: string | null;
conn_id: string | null;
}>(
`SELECT * FROM assignments WHERE player_id = ? AND conn_id = ?`,
playerId,
c.conn.id,
);
if (rows.length === 0) return null;
const row = rows[0]!;
return {
matchId: row.match_id,
playerId: row.player_id,
inviteCode: row.invite_code ?? undefined,
connId: row.conn_id,
} satisfies TurnBasedAssignment;
},
},
onDisconnect: async (c, conn) => {
await c.queue.send("unqueueForMatch", { connId: conn.id });
},
run: async (c) => {
for await (const message of c.queue.iter({ completable: true })) {
if (message.name === "createGame") {
const matchId = crypto.randomUUID();
const inviteCode = generateInviteCode();
const playerId = crypto.randomUUID();
const client = c.client<typeof registry>();
await client.turnBasedMatch.create([matchId], {
input: { matchId },
});
await client.turnBasedMatch.get([matchId]).createPlayer({
playerId,
playerName: message.body.playerName,
symbol: "X" as const,
});
await c.db.execute(
`INSERT INTO matches (match_id, invite_code, player_count, is_open_pool, created_at) VALUES (?, ?, ?, ?, ?)`,
matchId,
inviteCode,
1,
0,
Date.now(),
);
await message.complete({ matchId, playerId, inviteCode });
} else if (message.name === "joinByCode") {
const code = message.body.inviteCode.toUpperCase().trim();
const rows = await c.db.execute<{
match_id: string;
player_count: number;
}>(
`SELECT match_id, player_count FROM matches WHERE invite_code = ?`,
code,
);
const row = rows[0];
if (!row)
throw new UserError("Game not found", {
code: "game_not_found",
});
if (row.player_count >= 2)
throw new UserError("Game is full", { code: "game_full" });
const playerId = crypto.randomUUID();
const client = c.client<typeof registry>();
await client.turnBasedMatch.get([row.match_id]).createPlayer({
playerId,
playerName: message.body.playerName,
symbol: "O" as const,
});
await c.db.execute(
`UPDATE matches SET player_count = 2 WHERE match_id = ?`,
row.match_id,
);
await message.complete({ matchId: row.match_id, playerId });
} else if (message.name === "queueForMatch") {
await processQueueEntry(c, message.body);
await message.complete();
} else if (message.name === "unqueueForMatch") {
await c.db.execute(
`DELETE FROM player_pool WHERE conn_id = ?`,
message.body.connId,
);
await message.complete();
} else if (message.name === "closeMatch") {
await c.db.execute(
`DELETE FROM assignments WHERE match_id = ?`,
message.body.matchId,
);
await c.db.execute(
`DELETE FROM matches WHERE match_id = ?`,
message.body.matchId,
);
await message.complete();
}
}
},
});
async function processQueueEntry(
c: ActorContextOf<typeof turnBasedMatchmaker>,
entry: {
playerId: string;
playerName: string;
connId: string;
},
): Promise<void> {
await c.db.execute(
`INSERT OR REPLACE INTO player_pool (player_id, player_name, queued_at, conn_id) VALUES (?, ?, ?, ?)`,
entry.playerId,
entry.playerName,
Date.now(),
entry.connId,
);
await attemptPairing(c);
}
async function attemptPairing(
c: ActorContextOf<typeof turnBasedMatchmaker>,
): Promise<void> {
const queued = await c.db.execute<QueuePlayerRow>(
`SELECT player_id, player_name, queued_at, conn_id FROM player_pool ORDER BY queued_at ASC LIMIT 2`,
);
if (queued.length < 2) return;
const a = queued[0]!;
const b = queued[1]!;
await c.db.execute(
`DELETE FROM player_pool WHERE player_id = ?`,
a.player_id,
);
await c.db.execute(
`DELETE FROM player_pool WHERE player_id = ?`,
b.player_id,
);
const matchId = crypto.randomUUID();
const inviteCode = generateInviteCode();
const client = c.client<typeof registry>();
await client.turnBasedMatch.create([matchId], {
input: { matchId },
});
await client.turnBasedMatch.get([matchId]).createPlayer({
playerId: a.player_id,
playerName: a.player_name,
symbol: "X" as const,
});
await client.turnBasedMatch.get([matchId]).createPlayer({
playerId: b.player_id,
playerName: b.player_name,
symbol: "O" as const,
});
await c.db.execute(
`INSERT INTO matches (match_id, invite_code, player_count, is_open_pool, created_at) VALUES (?, ?, ?, ?, ?)`,
matchId,
inviteCode,
2,
1,
Date.now(),
);
const assignedPlayers = [a, b] as const;
for (const player of assignedPlayers) {
await c.db.execute(
`INSERT INTO assignments (player_id, match_id, invite_code, conn_id) VALUES (?, ?, ?, ?)`,
player.player_id,
matchId,
null,
player.conn_id,
);
c.broadcast("assignmentReady", {
matchId,
playerId: player.player_id,
connId: player.conn_id,
} satisfies TurnBasedAssignment);
}
}
async function migrateTables(dbHandle: RawAccess) {
await dbHandle.execute(`
CREATE TABLE IF NOT EXISTS matches (
match_id TEXT PRIMARY KEY,
invite_code TEXT NOT NULL UNIQUE,
player_count INTEGER NOT NULL,
is_open_pool INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
)
`);
await dbHandle.execute(`
CREATE TABLE IF NOT EXISTS player_pool (
player_id TEXT PRIMARY KEY,
player_name TEXT NOT NULL,
queued_at INTEGER NOT NULL,
conn_id TEXT
)
`);
await dbHandle.execute(`
CREATE TABLE IF NOT EXISTS assignments (
player_id TEXT PRIMARY KEY,
match_id TEXT NOT NULL,
invite_code TEXT,
conn_id TEXT
)
`);
}
- Every write goes through a queue that
runreads one message at a time. Two players can press “Join” at the same moment, and the queue makes sure only one of them takes the empty seat. createGamecreates theturnBasedMatchActor, seats the creator asXwithcreatePlayer, and stores the invite code in thematchestable.joinByCodelooks the code up, rejects a full game withgame_full, and seats the joiner asO.- The client sends
createGameandjoinByCodewithwait: true, so the result passed tomessage.complete(...)is the response. attemptPairingtakes the two oldest rows fromplayer_pool, creates a match, and broadcastsassignmentReady. Theassignmentsrow stores the queueing connection’s id, sogetAssignmentonly returns it to that connection.onDisconnectremoves a player from the queue when they close the tab.
Create the match
Each match is its own turnBasedMatch Actor, keyed by the match id. Its state is the whole game:
createState: (_c, input: { matchId: string }): State => {
const board: CellValue[][] = [];
for (let r = 0; r < BOARD_SIZE; r++) {
board.push(Array(BOARD_SIZE).fill(""));
}
return {
matchId: input.matchId,
board,
players: {},
currentTurn: "X",
result: null,
moveCount: 0,
emptySince: null,
};
},
onConnect: (c, conn) => {
const playerId = (conn.params as { playerId?: string })?.playerId;
if (!playerId) return;
const player = c.state.players[playerId];
if (!player) {
conn.disconnect("invalid_player");
return;
}
player.connId = conn.id;
c.state.emptySince = null;
broadcastSnapshot(c);
},
createStatebuilds an empty board. The matchmaker passes the match id as input when it creates the Actor.playersmaps a player id to a seat: name, symbol, and the id of the connection that is playing it.onConnectreadsplayerIdfrom the connection params and disconnects anyone who has not been seated withinvalid_player.- There is no tick loop. The match only changes when a player moves, so it broadcasts
gameUpdatefrom the action that changed it.
The player id is the only credential in this example. Anyone who knows it can connect as that player. In production, issue a signed token from the matchmaker and check it in onConnect, or use authentication.
Validate moves
makeMove is the only action that changes the board, so every rule of the game lives in it:
makeMove: (c, input: { row: number; col: number }) => {
if (c.state.result !== null) {
throw new UserError("game is over", { code: "game_over" });
}
const found = findPlayerByConnId(c.state, c.conn.id);
if (!found) {
throw new UserError("player not found", {
code: "player_not_found",
});
}
const [, player] = found;
if (player.symbol !== c.state.currentTurn) {
throw new UserError("not your turn", { code: "not_your_turn" });
}
const { row, col } = input;
if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) {
throw new UserError("invalid cell", { code: "invalid_cell" });
}
if (c.state.board[row]?.[col] !== "") {
throw new UserError("cell already taken", {
code: "cell_taken",
});
}
c.state.board[row]![col] = player.symbol;
c.state.moveCount += 1;
const winner = checkWinner(c.state.board);
if (winner) {
c.state.result = winner === "X" ? "x_wins" : "o_wins";
} else if (c.state.moveCount >= BOARD_SIZE * BOARD_SIZE) {
c.state.result = "draw";
} else {
c.state.currentTurn = c.state.currentTurn === "X" ? "O" : "X";
}
broadcastSnapshot(c);
},
- The player comes from
c.conn.id, not from the action input. A client cannot move for its opponent by sending a different name or symbol. - Each rule throws a
UserErrorwith a code:game_over,player_not_found,not_your_turn,invalid_cell, andcell_taken. Clients get the code and message, so the UI can say why a move was refused. - The server decides the result. It checks for a winner or a full board after every move, then flips
currentTurn. The client draws the board only from snapshots, so a move shows up as played oncegameUpdatearrives.
Sleep between turns
Turns can be hours apart, so a match should not hold anything open while it waits. This is how the example handles players leaving:
onDisconnect: (c, conn) => {
const found = findPlayerByConnId(c.state, conn.id);
if (!found) return;
const [, player] = found;
player.connId = null;
broadcastSnapshot(c);
const anyConnected = Object.values(c.state.players).some(
(p) => p.connId !== null,
);
if (!anyConnected) {
c.state.emptySince = Date.now();
}
},
run: async (c) => {
const tick = interval(1_000);
while (!c.aborted) {
await tick();
if (c.aborted) break;
const anyConnected = Object.values(c.state.players).some(
(p) => p.connId !== null,
);
if (anyConnected) {
c.state.emptySince = null;
continue;
}
if (c.state.emptySince === null) continue;
if (
Date.now() - c.state.emptySince >=
EMPTY_MATCH_DESTROY_DELAY_MS
) {
c.destroy();
break;
}
}
},
onDestroy: async (c) => {
const client = c.client<typeof registry>();
await client.turnBasedMatchmaker
.getOrCreate(["main"])
.send("closeMatch", { matchId: c.state.matchId });
},
onDisconnectclears the seat’s connection and recordsemptySincewhen the last player leaves.onConnectclears it again when someone comes back.- The
runloop checks once a second and callsc.destroy()once the match has been empty forEMPTY_MATCH_DESTROY_DELAY_MS(10 seconds). Tic-tac-toe is played in one sitting, so an empty match is an abandoned one. onDestroysendscloseMatchto the matchmaker, which deletes the match’s invite code and assignments.
For correspondence chess or a word game, keep the match instead of destroying it. Remove the run loop and onDestroy. The board, the turn, and the seats all live in c.state, which is saved automatically, so the Actor sleeps once nobody is connected and wakes with the same board on the next connect or makeMove. For a turn deadline, such as forfeiting a player who has not moved in three days, call c.schedule.after(...) after each move. A schedule wakes a sleeping Actor when it is due.
Register the Actors
Add both Actors to the registry and start it:
import { setup } from "rivetkit";
import { arenaMatch } from "./arena/match.ts";
import { arenaMatchmaker } from "./arena/matchmaker.ts";
import { battleRoyaleMatch } from "./battle-royale/match.ts";
import { battleRoyaleMatchmaker } from "./battle-royale/matchmaker.ts";
import { idleLeaderboard } from "./idle/leaderboard.ts";
import { idleWorld } from "./idle/world.ts";
import { ioStyleMatch } from "./io-style/match.ts";
import { ioStyleMatchmaker } from "./io-style/matchmaker.ts";
import { openWorldChunk } from "./open-world/chunk.ts";
import { partyMatch } from "./party/match.ts";
import { partyMatchmaker } from "./party/matchmaker.ts";
import { physics2dWorld } from "./physics-2d/world.ts";
import { physics3dWorld } from "./physics-3d/world.ts";
import { rankedLeaderboard } from "./ranked/leaderboard.ts";
import { rankedMatch } from "./ranked/match.ts";
import { rankedMatchmaker } from "./ranked/matchmaker.ts";
import { rankedPlayer } from "./ranked/player.ts";
import { turnBasedMatch } from "./turn-based/match.ts";
import { turnBasedMatchmaker } from "./turn-based/matchmaker.ts";
export {
arenaMatch,
arenaMatchmaker,
battleRoyaleMatch,
battleRoyaleMatchmaker,
idleLeaderboard,
idleWorld,
ioStyleMatch,
ioStyleMatchmaker,
openWorldChunk,
partyMatch,
partyMatchmaker,
physics2dWorld,
physics3dWorld,
rankedLeaderboard,
rankedMatch,
rankedMatchmaker,
rankedPlayer,
turnBasedMatch,
turnBasedMatchmaker,
};
export const registry = setup({
use: {
arenaMatch,
arenaMatchmaker,
battleRoyaleMatch,
battleRoyaleMatchmaker,
idleLeaderboard,
idleWorld,
ioStyleMatchmaker,
ioStyleMatch,
openWorldChunk,
partyMatch,
partyMatchmaker,
physics2dWorld,
physics3dWorld,
rankedLeaderboard,
rankedMatch,
rankedMatchmaker,
rankedPlayer,
turnBasedMatch,
turnBasedMatchmaker,
},
});
The example registers every game type in one registry. A turn-based game only needs turnBasedMatchmaker and turnBasedMatch. Each Actor file imports type { registry } so that c.client<typeof registry>() is typed when one Actor calls another.
Connect players
The menu asks the matchmaker for a game, then the game screen connects to the match with the player id it got back:
const createGame = async () => {
if (!name.trim()) return;
setStatus("loading");
setError("");
try {
const mm = client.turnBasedMatchmaker
.getOrCreate(["main"])
.connect();
const result = await mm.send(
"createGame",
{ playerName: name.trim() },
{ wait: true, timeout: 10_000 },
);
mm.dispose();
const response = result.response;
if (!response?.matchId) throw new Error("Failed to create game");
onReady(response);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setStatus("error");
}
};
const joinByCode = async () => {
if (!name.trim() || !joinCode.trim()) return;
setStatus("loading");
setError("");
try {
const mm = client.turnBasedMatchmaker
.getOrCreate(["main"])
.connect();
const result = await mm.send(
"joinByCode",
{ inviteCode: joinCode.trim(), playerName: name.trim() },
{ wait: true, timeout: 10_000 },
);
mm.dispose();
const response = result.response;
if (!response?.matchId) throw new Error("Failed to join game");
onReady(response);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setStatus("error");
}
};
useEffect(() => {
const conn = client.turnBasedMatch
.get([matchInfo.matchId], {
params: { playerId: matchInfo.playerId },
})
.connect();
connRef.current = conn;
conn.on("gameUpdate", (snap) => {
setSnapshot(snap);
});
conn.getSnapshot().then((snap) => {
setSnapshot(snap);
});
return () => {
cleanup();
for (const bot of botsRef.current) bot.destroy();
botsRef.current = [];
};
}, [client, matchInfo, cleanup]);
const addBot = () => {
if (matchInfo.inviteCode) {
botsRef.current.push(
new TurnBasedBot(client, matchInfo.inviteCode),
);
}
};
const makeMove = (row: number, col: number) => {
connRef.current?.makeMove({ row, col }).catch(() => {});
};
mm.send("createGame", ..., { wait: true })puts a message on the matchmaker’s queue and waits for the response. The matchmaker connection is disposed right after, because the player now talks only to the match.- The match connection passes
playerIdinparams. That is whatonConnectuses to find the seat. - The client subscribes to
gameUpdateand also callsgetSnapshotonce, so it has the board even if it connected after the last broadcast. - Quick Play calls the
queueForMatchaction and then waits forassignmentReady, with agetAssignmentcheck in case the event fired before the listener was attached.
Try it
git clone https://github.com/rivet-dev/rivet.git
cd rivet/examples/multiplayer-game-patterns
pnpm install
pnpm dev
Open http://localhost:5173 and pick Turn-Based. Click Create Private to get a six-character invite code. Open a second tab, enter the code under Join by Code, and play. Or click Add Bot to have a bot join. Each player’s board shows Your turn! or Opponent’s turn…, and the match refuses any move sent out of turn with not_your_turn.
Next steps
- Ranked Matchmaking: pair players by rating and keep a leaderboard.
- Schedule & Cron: add turn deadlines that wake a sleeping match.
- Lifecycle: how Actors sleep, wake, and get destroyed.