Skip to main content
Games

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.

See the full example on GitHub. View on GitHub
Player Acreates a gamePlayer Bjoins by codeturnBasedMatchmakerinvites and queueturnBasedMatchboard and turncreateGame1joinByCode2createPlayer3makeMove4gameUpdate to both players5One per matchRivet ActorCallReply

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

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

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.

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

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.

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:

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:

  • 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

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