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
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 adds players to 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, 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,
}),
events: {
assignmentReady: event<TurnBasedAssignment>(),
},
actions: {
createGame: async (c, { playerName }: { playerName: string }) => {
const matchId = crypto.randomUUID();
const inviteCode = generateInviteCode();
const playerId = crypto.randomUUID();
const client = c.client<typeof registry>();
await client.turnBasedMatch.create([matchId]);
await client.turnBasedMatch.get([matchId]).createPlayer({
playerId,
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(),
);
return { matchId, playerId, inviteCode };
},
joinByCode: async (
c,
input: { inviteCode: string; playerName: string },
) => {
const code = input.inviteCode.toUpperCase().trim();
// Actions run concurrently, so check and take the open seat in one
// transaction. Two players joining with the same code can't both
// get it.
const matchId = await c.db.transaction(
async (tx) => {
const [match] = await tx.execute<{
match_id: string;
player_count: number;
}>(
`SELECT match_id, player_count FROM matches WHERE invite_code = ?`,
code,
);
if (!match)
throw new UserError("Game not found", {
code: "game_not_found",
});
if (match.player_count >= 2)
throw new UserError("Game is full", {
code: "game_full",
});
await tx.execute(
`UPDATE matches SET player_count = 2 WHERE match_id = ?`,
match.match_id,
);
return match.match_id;
},
{ name: "claim-seat" },
);
// Seat the player outside the transaction, because a transaction
// must not wait on another actor.
const playerId = crypto.randomUUID();
const client = c.client<typeof registry>();
try {
await client.turnBasedMatch.get([matchId]).createPlayer({
playerId,
playerName: input.playerName,
symbol: "O" as const,
});
} catch (error) {
// Open the seat again, so the code still works for someone else.
await c.db.execute(
`UPDATE matches SET player_count = 1 WHERE match_id = ?`,
matchId,
);
throw error;
}
return { matchId, playerId };
},
queueForMatch: async (c, { playerName }: { playerName: string }) => {
const playerId = crypto.randomUUID();
await c.db.execute(
`INSERT OR REPLACE INTO player_pool (player_id, player_name, queued_at, conn_id) VALUES (?, ?, ?, ?)`,
playerId,
playerName,
Date.now(),
c.conn.id,
);
await attemptPairing(c);
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;
},
closeMatch: async (c, { matchId }: { matchId: string }) => {
await c.db.transaction(
async (tx) => {
await tx.execute(
`DELETE FROM assignments WHERE match_id = ?`,
matchId,
);
await tx.execute(
`DELETE FROM matches WHERE match_id = ?`,
matchId,
);
},
{ name: "close-match" },
);
},
},
onDisconnect: async (c, conn) => {
await c.db.execute(
`DELETE FROM player_pool WHERE conn_id = ?`,
conn.id,
);
},
});
async function attemptPairing(
c: ActorContextOf<typeof turnBasedMatchmaker>,
): Promise<void> {
// Actions run concurrently, so take the two oldest players in one
// transaction. Two players queueing at once can't both pair with the
// same opponent.
const queued = await c.db.transaction(
async (tx) => {
const rows = await tx.execute<QueuePlayerRow>(
`SELECT player_id, player_name, queued_at, conn_id FROM player_pool ORDER BY queued_at ASC LIMIT 2`,
);
if (rows.length < 2) return [];
for (const row of rows) {
await tx.execute(
`DELETE FROM player_pool WHERE player_id = ?`,
row.player_id,
);
}
return rows;
},
{ name: "claim-players" },
);
if (queued.length < 2) return;
const a = queued[0]!;
const b = queued[1]!;
const matchId = crypto.randomUUID();
const inviteCode = generateInviteCode();
// Create the match outside the transaction, because a transaction must not
// wait on another actor.
const client = c.client<typeof registry>();
try {
await client.turnBasedMatch.create([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,
});
} catch (error) {
await returnToPool(c, queued);
throw error;
}
await c.db.transaction(
async (tx) => {
await tx.execute(
`INSERT INTO matches (match_id, invite_code, player_count, is_open_pool, created_at) VALUES (?, ?, ?, ?, ?)`,
matchId,
inviteCode,
2,
1,
Date.now(),
);
for (const player of queued) {
await tx.execute(
`INSERT INTO assignments (player_id, match_id, invite_code, conn_id) VALUES (?, ?, ?, ?)`,
player.player_id,
matchId,
null,
player.conn_id,
);
}
},
{ name: "save-match" },
);
for (const player of queued) {
c.broadcast("assignmentReady", {
matchId,
playerId: player.player_id,
connId: player.conn_id,
} satisfies TurnBasedAssignment);
}
}
/** Puts claimed players back at their original place in the pool. */
async function returnToPool(
c: ActorContextOf<typeof turnBasedMatchmaker>,
players: QueuePlayerRow[],
): Promise<void> {
// A player who left while the match was being created stays out of the
// pool, because their disconnect found no row to remove.
const stillConnected = players.filter(
(player) => player.conn_id !== null && c.conns.has(player.conn_id),
);
await c.db.transaction(
async (tx) => {
for (const player of stillConnected) {
await tx.execute(
`INSERT INTO player_pool (player_id, player_name, queued_at, conn_id) VALUES (?, ?, ?, ?)`,
player.player_id,
player.player_name,
player.queued_at,
player.conn_id,
);
}
},
{ name: "return-players" },
);
}
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 request is an action that returns its result to the caller.
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. - Actions run at the same time, so
joinByCodechecks for an open seat and takes it in onec.db.transaction. Two players can press “Join” at the same moment, and only one of them gets the seat. The joiner is seated after the transaction commits, because a transaction must not wait on another Actor. If seating fails, the seat opens again. queueForMatchadds the player toplayer_pooland callsattemptPairing, which takes the two oldest players in one transaction, creates a match, and broadcastsassignmentReady. If creating the match fails,returnToPoolputs both players back. 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 SQLite database holds the whole game:
db: db({
onMigrate: async (db) => {
await db.execute(`
CREATE TABLE IF NOT EXISTS game (
id INTEGER PRIMARY KEY CHECK (id = 1),
board TEXT NOT NULL,
current_turn TEXT NOT NULL,
result TEXT,
move_count INTEGER NOT NULL
)
`);
await db.execute(`
CREATE TABLE IF NOT EXISTS players (
player_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
symbol TEXT NOT NULL
)
`);
await db.execute(
"INSERT OR IGNORE INTO game (id, board, current_turn, result, move_count) VALUES (1, ?, 'X', NULL, 0)",
JSON.stringify(emptyBoard()),
);
},
}),
// Connections end when the Actor stops, so the connection playing each seat
// only lives in memory.
createVars: (): MatchVars => ({
connIds: new Map(),
emptySince: null,
}),
onConnect: async (c, conn) => {
const playerId = (conn.params as { playerId?: string })?.playerId;
if (!playerId) return;
const [seat] = await c.db.execute(
"SELECT player_id FROM players WHERE player_id = ?",
playerId,
);
if (!seat) {
conn.disconnect("invalid_player");
return;
}
c.vars.connIds.set(playerId, conn.id);
c.vars.emptySince = null;
await broadcastSnapshot(c);
},
onMigratecreates agamerow with an empty board and aplayerstable for the seats.createPlayersaves each seat. The match id comes fromc.key, so the matchmaker passes no input.c.vars.connIdsmaps a seated player id to the connection playing it. It only lives in memory, because connections do not outlive the Actor.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: async (c, input: { row: number; col: number }) => {
const playerId = findPlayerByConnId(c.vars, c.conn.id);
// Check and save the move in one transaction, so two moves sent at
// once cannot both pass the turn check.
await c.db.transaction(
async (tx) => {
const [game] = await tx.execute<GameRow>(
"SELECT board, current_turn, result, move_count FROM game WHERE id = 1",
);
if (game!.result !== null) {
throw new UserError("game is over", {
code: "game_over",
});
}
// A connection without a seat has a null player id and finds no row.
const [player] = await tx.execute<{ symbol: "X" | "O" }>(
"SELECT symbol FROM players WHERE player_id = ?",
playerId,
);
if (!player) {
throw new UserError("player not found", {
code: "player_not_found",
});
}
if (player.symbol !== game!.current_turn) {
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" });
}
const board = JSON.parse(game!.board) as CellValue[][];
if (board[row]?.[col] !== "") {
throw new UserError("cell already taken", {
code: "cell_taken",
});
}
board[row]![col] = player.symbol;
const moveCount = game!.move_count + 1;
let currentTurn = game!.current_turn;
let result: GameResult = null;
const winner = checkWinner(board);
if (winner) {
result = winner === "X" ? "x_wins" : "o_wins";
} else if (moveCount >= BOARD_SIZE * BOARD_SIZE) {
result = "draw";
} else {
currentTurn = currentTurn === "X" ? "O" : "X";
}
await tx.execute(
"UPDATE game SET board = ?, current_turn = ?, result = ?, move_count = ? WHERE id = 1",
JSON.stringify(board),
currentTurn,
result,
moveCount,
);
},
{ name: "make-move" },
);
await 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 checks and the save run in one
c.db.transactionthat reads thegamerow first. Two moves sent at once cannot both pass the turn check. - The server decides the result. It checks for a winner or a full board after every move, flips the turn, and saves the board, turn, and result to the
gamerow. 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:
import { type ActorContextOf, actor, event, UserError } from "rivetkit";
import { db } from "rivetkit/db";
import type { registry } from "../index.ts";
import { getPlayerColor } from "../player-color.ts";
import { BOARD_SIZE, type CellValue, type GameResult } from "./config.ts";
const EMPTY_MATCH_DESTROY_DELAY_MS = 10_000;
type GameRow = {
board: string;
current_turn: "X" | "O";
result: GameResult;
move_count: number;
};
interface MatchVars {
// The live connection playing each seat, keyed by player id.
connIds: Map<string, string>;
// When the last player left. It is null while a player is connected and
// after a restart.
emptySince: number | null;
}
export const turnBasedMatch = actor({
options: { name: "Turn-Based - Match", icon: "chess-board" },
events: {
gameUpdate: event<GameSnapshot>(),
},
db: db({
onMigrate: async (db) => {
await db.execute(`
CREATE TABLE IF NOT EXISTS game (
id INTEGER PRIMARY KEY CHECK (id = 1),
board TEXT NOT NULL,
current_turn TEXT NOT NULL,
result TEXT,
move_count INTEGER NOT NULL
)
`);
await db.execute(`
CREATE TABLE IF NOT EXISTS players (
player_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
symbol TEXT NOT NULL
)
`);
await db.execute(
"INSERT OR IGNORE INTO game (id, board, current_turn, result, move_count) VALUES (1, ?, 'X', NULL, 0)",
JSON.stringify(emptyBoard()),
);
},
}),
// Connections end when the Actor stops, so the connection playing each seat
// only lives in memory.
createVars: (): MatchVars => ({
connIds: new Map(),
emptySince: null,
}),
onConnect: async (c, conn) => {
const playerId = (conn.params as { playerId?: string })?.playerId;
if (!playerId) return;
const [seat] = await c.db.execute(
"SELECT player_id FROM players WHERE player_id = ?",
playerId,
);
if (!seat) {
conn.disconnect("invalid_player");
return;
}
c.vars.connIds.set(playerId, conn.id);
c.vars.emptySince = null;
await broadcastSnapshot(c);
},
onDisconnect: async (c, conn) => {
const playerId = findPlayerByConnId(c.vars, conn.id);
if (!playerId) return;
c.vars.connIds.delete(playerId);
// A shutdown disconnects everyone, but those players come back after
// the restart, so only a real leave starts the countdown.
if (c.vars.connIds.size === 0 && !c.aborted) {
// A schedule survives a sleep or restart, which a timer in memory
// would not, and it wakes the Actor to run.
const emptySince = Date.now();
c.vars.emptySince = emptySince;
await c.schedule.after(
EMPTY_MATCH_DESTROY_DELAY_MS,
"destroyIfEmpty",
emptySince,
);
}
await broadcastSnapshot(c);
},
onDestroy: async (c) => {
const client = c.client<typeof registry>();
await client.turnBasedMatchmaker
.getOrCreate(["main"])
.closeMatch({ matchId: c.key[0]! });
},
actions: {
destroyIfEmpty: (c, emptySince: number) => {
if (!c.scheduledFire) {
throw new UserError("destroyIfEmpty only runs from a schedule", {
code: "schedule_only",
});
}
// Keep the match if a player came back. A player who came back and
// left again armed a newer schedule, so only that one may destroy
// the match. After a restart `emptySince` is null and nobody has
// connected since.
if (c.vars.connIds.size > 0) return;
if (
c.vars.emptySince !== null &&
c.vars.emptySince !== emptySince
) {
return;
}
c.destroy();
},
createPlayer: async (
c,
input: { playerId: string; playerName: string; symbol: "X" | "O" },
) => {
await c.db.execute(
"INSERT OR REPLACE INTO players (player_id, name, symbol) VALUES (?, ?, ?)",
input.playerId,
input.playerName,
input.symbol,
);
},
makeMove: async (c, input: { row: number; col: number }) => {
const playerId = findPlayerByConnId(c.vars, c.conn.id);
// Check and save the move in one transaction, so two moves sent at
// once cannot both pass the turn check.
await c.db.transaction(
async (tx) => {
const [game] = await tx.execute<GameRow>(
"SELECT board, current_turn, result, move_count FROM game WHERE id = 1",
);
if (game!.result !== null) {
throw new UserError("game is over", {
code: "game_over",
});
}
// A connection without a seat has a null player id and finds no row.
const [player] = await tx.execute<{ symbol: "X" | "O" }>(
"SELECT symbol FROM players WHERE player_id = ?",
playerId,
);
if (!player) {
throw new UserError("player not found", {
code: "player_not_found",
});
}
if (player.symbol !== game!.current_turn) {
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" });
}
const board = JSON.parse(game!.board) as CellValue[][];
if (board[row]?.[col] !== "") {
throw new UserError("cell already taken", {
code: "cell_taken",
});
}
board[row]![col] = player.symbol;
const moveCount = game!.move_count + 1;
let currentTurn = game!.current_turn;
let result: GameResult = null;
const winner = checkWinner(board);
if (winner) {
result = winner === "X" ? "x_wins" : "o_wins";
} else if (moveCount >= BOARD_SIZE * BOARD_SIZE) {
result = "draw";
} else {
currentTurn = currentTurn === "X" ? "O" : "X";
}
await tx.execute(
"UPDATE game SET board = ?, current_turn = ?, result = ?, move_count = ? WHERE id = 1",
JSON.stringify(board),
currentTurn,
result,
moveCount,
);
},
{ name: "make-move" },
);
await broadcastSnapshot(c);
},
getSnapshot: (c): Promise<GameSnapshot> => buildSnapshot(c),
},
});
function checkWinner(board: CellValue[][]): "X" | "O" | null {
for (let r = 0; r < BOARD_SIZE; r++) {
if (
board[r]?.[0] !== "" &&
board[r]?.[0] === board[r]?.[1] &&
board[r]?.[1] === board[r]?.[2]
) {
return board[r]?.[0] as "X" | "O";
}
}
for (let c = 0; c < BOARD_SIZE; c++) {
if (
board[0]?.[c] !== "" &&
board[0]?.[c] === board[1]?.[c] &&
board[1]?.[c] === board[2]?.[c]
) {
return board[0]?.[c] as "X" | "O";
}
}
if (
board[0]?.[0] !== "" &&
board[0]?.[0] === board[1]?.[1] &&
board[1]?.[1] === board[2]?.[2]
) {
return board[0]?.[0] as "X" | "O";
}
if (
board[0]?.[2] !== "" &&
board[0]?.[2] === board[1]?.[1] &&
board[1]?.[1] === board[2]?.[0]
) {
return board[0]?.[2] as "X" | "O";
}
return null;
}
interface GameSnapshot {
matchId: string;
board: CellValue[][];
currentTurn: "X" | "O";
result: GameResult;
moveCount: number;
players: Record<
string,
{ name: string; color: string; symbol: "X" | "O"; connected: boolean }
>;
}
async function buildSnapshot(
c: ActorContextOf<typeof turnBasedMatch>,
): Promise<GameSnapshot> {
const [game] = await c.db.execute<GameRow>(
"SELECT board, current_turn, result, move_count FROM game WHERE id = 1",
);
// Seats are listed in the order they were taken.
const seats = await c.db.execute<{
player_id: string;
name: string;
symbol: "X" | "O";
}>("SELECT player_id, name, symbol FROM players ORDER BY rowid");
const players: GameSnapshot["players"] = {};
for (const seat of seats) {
players[seat.player_id] = {
name: seat.name,
color: getPlayerColor(seat.player_id),
symbol: seat.symbol,
connected: c.vars.connIds.has(seat.player_id),
};
}
return {
matchId: c.key[0]!,
board: JSON.parse(game!.board) as CellValue[][],
currentTurn: game!.current_turn,
result: game!.result,
moveCount: game!.move_count,
players,
};
}
async function broadcastSnapshot(c: ActorContextOf<typeof turnBasedMatch>) {
c.broadcast("gameUpdate", await buildSnapshot(c));
}
function emptyBoard(): CellValue[][] {
const board: CellValue[][] = [];
for (let r = 0; r < BOARD_SIZE; r++) {
board.push(Array(BOARD_SIZE).fill(""));
}
return board;
}
function findPlayerByConnId(vars: MatchVars, connId: string): string | null {
for (const [playerId, playerConnId] of vars.connIds) {
if (playerConnId === connId) return playerId;
}
return null;
}
onDisconnectclears the seat’s connection. When the last player leaves, and the match is not shutting down, it recordsemptySinceand callsc.schedule.after(EMPTY_MATCH_DESTROY_DELAY_MS, "destroyIfEmpty", emptySince).onConnectclearsemptySinceagain when someone comes back.- The schedule is saved with the Actor, so it still runs
destroyIfEmptyon time after a restart or a sleep, and it wakes the Actor to do it. A shutdown disconnects everyone too, but those players reconnect afterward, so it does not start the countdown. destroyIfEmptyrefuses to run unless a schedule called it (c.scheduledFire), so a client cannot destroy someone else’s match. It checks again before it callsc.destroy(). It keeps the match if a player is connected, and ignores an older schedule if a player came back and left again, so the match is destroyedEMPTY_MATCH_DESTROY_DELAY_MS(10 seconds) after the last player leaves. Tic-tac-toe is played in one sitting, so an empty match is an abandoned one.onDestroycallscloseMatchon 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 destroyIfEmpty, the schedule in onDisconnect, and onDestroy. The board, the turn, and the seats are already saved in SQLite after every change, 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 {
// A one-off action needs no connection to the matchmaker.
const mm = client.turnBasedMatchmaker.getOrCreate(["main"]);
const game = await mm.createGame({ playerName: name.trim() });
onReady(game);
} 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"]);
const game = await mm.joinByCode({
inviteCode: joinCode.trim(),
playerName: name.trim(),
});
onReady(game);
} 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.createGame(...)andmm.joinByCode(...)are action calls that return the match id and player id, plus the invite code for a new game. They go through a plain actor handle without opening a connection, because the player talks only to the match afterward.- 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.