# Turn-Based Game

Player A calls createGame on the turnBasedMatchmaker Actor and gets back an invite code. Player B calls joinByCode with that code. The matchmaker calls createPlayer on the turnBasedMatch Actor to seat each player as X or O. The players connect to the match and call makeMove on their turn. The match validates each move and broadcasts the gameUpdate event to both players. The numbers show the order of the calls. There is one matchmaker and one turnBasedMatch Actor per match.

What we'll build:

- A `turnBasedMatchmaker` Actor that creates private games with invite codes and pairs players from a public queue.
- A `turnBasedMatch` Actor 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

```sh
npm add rivetkit@next
```

### 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:

matchmaker.ts:

```ts
/*
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 `run` reads 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.
- `createGame` creates the `turnBasedMatch` Actor, seats the creator as `X` with `createPlayer`, and stores the invite code in the `matches` table. `joinByCode` looks the code up, rejects a full game with `game_full`, and seats the joiner as `O`.
- The client sends `createGame` and `joinByCode` with `wait: true`, so the result passed to `message.complete(...)` is the response.
- `attemptPairing` takes the two oldest rows from `player_pool`, creates a match, and broadcasts `assignmentReady`. The `assignments` row stores the queueing connection's id, so `getAssignment` only returns it to that connection. `onDisconnect` removes 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:

match.ts:

```ts
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);
},
```

- `createState` builds an empty board. The matchmaker passes the match id as input when it creates the Actor.
- `players` maps a player id to a seat: name, symbol, and the id of the connection that is playing it. `onConnect` reads `playerId` from the connection params and disconnects anyone who has not been seated with `invalid_player`.
- There is no tick loop. The match only changes when a player moves, so it broadcasts `gameUpdate` from the action that changed it.

> **WARNING:** 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](/guides/authentication).

### Validate moves

`makeMove` is the only action that changes the board, so every rule of the game lives in it:

match.ts:

```ts
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 `UserError` with a code: `game_over`, `player_not_found`, `not_your_turn`, `invalid_cell`, and `cell_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 once `gameUpdate` arrives.

### 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:

match.ts:

```ts
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 });
},
```

- `onDisconnect` clears the seat's connection and records `emptySince` when the last player leaves. `onConnect` clears it again when someone comes back.
- The `run` loop checks once a second and calls `c.destroy()` once the match has been empty for `EMPTY_MATCH_DESTROY_DELAY_MS` (10 seconds). Tic-tac-toe is played in one sitting, so an empty match is an abandoned one.
- `onDestroy` sends `closeMatch` to the matchmaker, which deletes the match's invite code and assignments.

> **NOTE:** 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](/actors/docs/lifecycle#sleeping) 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](/actors/docs/schedule) wakes a sleeping Actor when it is due.

### Register the Actors

Add both Actors to the registry and start it:

actors/index.ts:

```ts
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,
	},
});
```

index.ts:

```ts
import { registry } from "./actors/index.ts";

export { registry };

registry.start();
```

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:

menu.tsx:

```tsx
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");
	}
};
```

game.tsx:

```tsx
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 `playerId` in `params`. That is what `onConnect` uses to find the seat.
- The client subscribes to `gameUpdate` and also calls `getSnapshot` once, so it has the board even if it connected after the last broadcast.
- Quick Play calls the `queueForMatch` action and then waits for `assignmentReady`, with a `getAssignment` check in case the event fired before the listener was attached.

### Try it

```sh
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](/guides/ranked-game): pair players by rating and keep a leaderboard.
- [Schedule & Cron](/actors/docs/schedule): add turn deadlines that wake a sleeping match.
- [Lifecycle](/actors/docs/lifecycle): how Actors sleep, wake, and get destroyed.
