Skip to main content
Games

Arena Game

Build an arena game such as a team deathmatch, a free-for-all, or a Rocket League style match, where players queue for a mode and play a fixed-size match.

See the full example on GitHub. View on GitHub
Game clientbrowser canvasarenaMatchmakerone, keyed mainMatchmaker SQLiteplayer_pool, assignmentsarenaMatchteams and scorequeueForMatch1create with teams2assignmentReady3connect with playerId4c.db.executeupdatePosition, shoot5snapshot, shoot events6matchCompleted7One per matchRivet ActorCallReply

What we’ll build:

  • An arenaMatchmaker Actor with one queue per mode (1v1, ffa, duo, and squad) that only creates a match once enough players are waiting.
  • Teams assigned by the matchmaker before the match exists, or no teams for free-for-all.
  • An arenaMatch Actor per match that waits for every player, then runs at 20 ticks per second until a side reaches 10 kills.
  • Movement that the client owns and the server checks, and hits that only the server resolves.

Install

npm add rivetkit

Create the matchmaker

One arenaMatchmaker Actor, keyed ["main"], holds a waiting pool per mode and builds full matches from it. The pool, matches, and assignments live in its SQLite database:

/*
This matchmaker uses a queue and fill flow.
1. queueForMatch adds players to player_pool for a specific mode.
2. Once enough players are queued for that mode, fillMatch removes them from the pool and creates a match actor.
3. The matchmaker stores assignments and broadcasts assignmentReady so each client can connect to the new match.
4. onDisconnect unqueues pending players so stale queue entries do not stay in the pool.
*/
import { type ActorContextOf, actor, event } from "rivetkit";
import { db, type RawAccess } from "rivetkit/db";

import type { registry } from "../index.ts";
import { MODE_CONFIG, type Mode } from "./config.ts";

export interface ArenaAssignment {
	matchId: string;
	playerId: string;
	teamId: number;
	mode: Mode;
	connId: string | null;
}

type QueuePlayerRow = {
	player_id: string;
	conn_id: string | null;
	queued_at: number;
};

export const arenaMatchmaker = actor({
	options: { name: "Arena - Matchmaker", icon: "crosshairs" },
	db: db({
		onMigrate: migrateTables,
	}),
	events: {
		assignmentReady: event<ArenaAssignment>(),
		queueUpdate: event<{ counts: Record<string, number> }>(),
	},
	actions: {
		queueForMatch: async (c, { mode }: { mode: Mode }) => {
			const playerId = crypto.randomUUID();
			await c.db.execute(
				`INSERT INTO player_pool (player_id, mode, queued_at, conn_id) VALUES (?, ?, ?, ?)`,
				playerId,
				mode,
				Date.now(),
				c.conn.id,
			);
			await broadcastQueueSizes(c);
			await fillMatch(c, mode);
			return { playerId };
		},
		getQueueSizes: async (c) => {
			const rows = await c.db.execute<{ mode: string; cnt: number }>(
				`SELECT mode, COUNT(*) as cnt FROM player_pool GROUP BY mode`,
			);
			const counts: Record<string, number> = {};
			for (const row of rows) {
				counts[row.mode] = row.cnt;
			}
			return counts;
		},
		getAssignment: async (c, { playerId }: { playerId: string }) => {
			const rows = await c.db.execute<{
				match_id: string;
				player_id: string;
				team_id: number;
				mode: string;
				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,
				teamId: row.team_id,
				mode: row.mode as Mode,
				connId: row.conn_id,
			};
		},
		matchCompleted: async (c, { matchId }: { matchId: string }) => {
			await c.db.execute(`DELETE FROM matches WHERE match_id = ?`, matchId);
		},
	},
	onDisconnect: async (c, conn) => {
		await c.db.execute(`DELETE FROM player_pool WHERE conn_id = ?`, conn.id);
		await broadcastQueueSizes(c);
	},
});

async function broadcastQueueSizes(c: ActorContextOf<typeof arenaMatchmaker>) {
	const rows = await c.db.execute<{ mode: string; cnt: number }>(
		`SELECT mode, COUNT(*) as cnt FROM player_pool GROUP BY mode`,
	);
	const counts: Record<string, number> = {};
	for (const row of rows) {
		counts[row.mode] = row.cnt;
	}
	c.broadcast("queueUpdate", { counts });
}

async function fillMatch(c: ActorContextOf<typeof arenaMatchmaker>, mode: Mode) {
	const config = MODE_CONFIG[mode];

	// Actions run concurrently, so check the count and remove the players in
	// one transaction. Two players joining at once can't both fill the last slot.
	const queued = await c.db.transaction(
		async (tx) => {
			const rows = await tx.execute<QueuePlayerRow>(
				`SELECT player_id, conn_id, queued_at FROM player_pool WHERE mode = ? ORDER BY queued_at ASC LIMIT ?`,
				mode,
				config.capacity,
			);
			if (rows.length < config.capacity) 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 === 0) return;

	const matchId = crypto.randomUUID();
	const assignedPlayers = queued.map((row, idx) => ({
		playerId: row.player_id,
		connId: row.conn_id,
		teamId: config.teams > 0 ? idx % config.teams : -1,
	}));

	// Create the match outside the transaction, because a transaction must not
	// wait on another actor.
	const client = c.client<typeof registry>();
	try {
		await client.arenaMatch.create([matchId], {
			input: {
				matchId,
				mode,
				capacity: config.capacity,
				assignedPlayers: assignedPlayers.map((ap) => ({
					playerId: ap.playerId,
					teamId: ap.teamId,
				})),
			},
		});
	} catch (error) {
		await returnToPool(c, mode, queued);
		throw error;
	}

	await c.db.transaction(
		async (tx) => {
			await tx.execute(
				`INSERT INTO matches (match_id, mode, capacity, created_at) VALUES (?, ?, ?, ?)`,
				matchId,
				mode,
				config.capacity,
				Date.now(),
			);
			for (const ap of assignedPlayers) {
				await tx.execute(
					`INSERT INTO assignments (player_id, match_id, team_id, mode, conn_id) VALUES (?, ?, ?, ?, ?)`,
					ap.playerId,
					matchId,
					ap.teamId,
					mode,
					ap.connId,
				);
			}
		},
		{ name: "save-match" },
	);

	await broadcastQueueSizes(c);

	for (const ap of assignedPlayers) {
		c.broadcast("assignmentReady", {
			matchId,
			playerId: ap.playerId,
			teamId: ap.teamId,
			mode,
			connId: ap.connId,
		} satisfies ArenaAssignment);
	}
}

/** Puts claimed players back at their original place in the pool. */
async function returnToPool(
	c: ActorContextOf<typeof arenaMatchmaker>,
	mode: Mode,
	players: QueuePlayerRow[],
) {
	// 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, mode, queued_at, conn_id) VALUES (?, ?, ?, ?)`,
					player.player_id,
					mode,
					player.queued_at,
					player.conn_id,
				);
			}
		},
		{ name: "return-players" },
	);
	await broadcastQueueSizes(c);
}

async function migrateTables(dbHandle: RawAccess) {
	await dbHandle.execute(`
		CREATE TABLE IF NOT EXISTS player_pool (
			player_id TEXT PRIMARY KEY,
			mode TEXT NOT NULL,
			queued_at INTEGER NOT NULL,
			conn_id TEXT
		)
	`);
	await dbHandle.execute(`
		CREATE TABLE IF NOT EXISTS matches (
			match_id TEXT PRIMARY KEY,
			mode TEXT NOT NULL,
			capacity INTEGER NOT NULL,
			created_at INTEGER NOT NULL
		)
	`);
	await dbHandle.execute(`
		CREATE TABLE IF NOT EXISTS assignments (
			player_id TEXT PRIMARY KEY,
			match_id TEXT NOT NULL,
			team_id INTEGER NOT NULL,
			mode TEXT NOT NULL,
			conn_id TEXT
		)
	`);
}
  • queueForMatch is an action. It creates the playerId, adds the player to player_pool with c.conn.id, sends the new counts as queueUpdate, and calls fillMatch. The match arrives as an event, because nobody knows how long the wait will be.
  • Actions run at the same time, so fillMatch checks the count and removes the players who have waited longest in one c.db.transaction. Two players joining at once can’t both fill the last slot.
  • fillMatch assigns teams by position (idx % teams, or -1 for free-for-all). After the transaction commits, it creates the arenaMatch Actor with the roster as input, saves the match and its assignments, and broadcasts assignmentReady. If creating the match fails, returnToPool puts the players back in their old places.
  • onDisconnect deletes the player’s row, so a player who closes the tab leaves the pool.

assignmentReady is broadcast to every client connected to the matchmaker, and the playerId in it is enough to connect as that player. In production, send each assignment only to its owner with conn.send(...) (the row already stores conn_id), and check a signed token in the match.

Create the match

Each match is its own arenaMatch Actor, keyed by the match id. It starts with the full roster and goes live once everyone has connected:

db: db({
	onMigrate: async (db) => {
		await db.execute(`
			CREATE TABLE IF NOT EXISTS match_info (
				id INTEGER PRIMARY KEY CHECK (id = 1),
				match_id TEXT NOT NULL,
				mode TEXT NOT NULL,
				capacity INTEGER NOT NULL,
				tick INTEGER NOT NULL DEFAULT 0,
				phase TEXT NOT NULL DEFAULT 'waiting',
				winner_team INTEGER,
				winner_player_id TEXT
			)
		`);
		await db.execute(`
			CREATE TABLE IF NOT EXISTS roster (
				player_id TEXT PRIMARY KEY,
				team_id INTEGER NOT NULL,
				x REAL NOT NULL,
				y REAL NOT NULL,
				score INTEGER NOT NULL DEFAULT 0
			)
		`);
	},
}),
onCreate: async (
	c,
	input: {
		matchId: string;
		mode: Mode;
		capacity: number;
		assignedPlayers: AssignedPlayer[];
	},
) => {
	// Save the settings and the roster once, with a random spawn point for
	// each player, so every start of this match sees the same players.
	await c.db.transaction(
		async (tx) => {
			await tx.execute(
				"INSERT INTO match_info (id, match_id, mode, capacity) VALUES (1, ?, ?, ?)",
				input.matchId,
				input.mode,
				input.capacity,
			);
			for (const ap of input.assignedPlayers) {
				await tx.execute(
					"INSERT INTO roster (player_id, team_id, x, y) VALUES (?, ?, ?, ?)",
					ap.playerId,
					ap.teamId,
					Math.random() * WORLD_SIZE,
					Math.random() * WORLD_SIZE,
				);
			}
		},
		{ name: "create-match" },
	);
},
createVars: async (c): Promise<MatchVars> => {
	const [info] = await c.db.execute<MatchInfoRow>(
		"SELECT match_id, mode, capacity, tick, phase, winner_team, winner_player_id FROM match_info WHERE id = 1",
	);
	const roster = await c.db.execute<RosterRow>(
		"SELECT player_id, team_id, x, y, score FROM roster",
	);

	// The live match runs in memory and starts from the last save. No
	// connection survives a restart, so every player starts unbound.
	const players: Record<string, PlayerEntry> = {};
	for (const row of roster) {
		players[row.player_id] = {
			connId: null,
			teamId: row.team_id,
			color: getPlayerColor(row.player_id),
			x: row.x,
			y: row.y,
			lastPositionAt: Date.now(),
			alive: true,
			score: row.score,
		};
	}
	return {
		matchId: info!.match_id,
		mode: info!.mode,
		capacity: info!.capacity,
		tick: info!.tick,
		phase: info!.phase,
		players,
		winnerTeam: info!.winner_team,
		winnerPlayerId: info!.winner_player_id,
		loop: null,
	};
},
onConnect: (c, conn) => {
	const playerId = (conn.params as { playerId?: string })?.playerId;
	if (!playerId) return;
	const player = c.vars.players[playerId];
	if (!player) {
		conn.disconnect("invalid_player");
		return;
	}
	player.connId = conn.id;

	if (c.vars.phase === "waiting") {
		const allConnected = Object.values(c.vars.players).every(
			(p) => p.connId !== null,
		);
		if (allConnected) {
			c.vars.phase = "live";
		}
	}

	broadcastSnapshot(c);
},
  • onCreate runs once and saves the match settings and the roster from the input, with a random spawn point for each player, into the match’s SQLite database. createVars loads them on every start, with the phase, tick, positions, and scores from the last save, into c.vars. Every player starts without a connection, because none survives a restart. The roster is fixed, so onConnect doesn’t need to ask the matchmaker. An id that isn’t on the roster is disconnected with invalid_player.
  • The match stays in waiting until every assigned player has a connection, then switches to live. Nobody starts a 4v4 with three players.
  • A disconnect only clears the player’s connection id. Their seat and score stay, so they can reconnect into the same match.

Validate movement and score hits

The match runs a 20 tick per second loop, and checks every move and shot that arrives between ticks:

import { type ActorContextOf, actor, event, UserError } from "rivetkit";
import { db } from "rivetkit/db";
import { interval } from "rivetkit/utils";
import type { registry } from "../index.ts";
import { getPlayerColor } from "../player-color.ts";
import {
	MAX_SPEED,
	type Mode,
	SCORE_LIMIT,
	SHOOT_ANGLE,
	SHOOT_RANGE,
	SNAPSHOT_INTERVAL_MS,
	TICK_MS,
	WORLD_SIZE,
} from "./config.ts";

interface PlayerEntry {
	connId: string | null;
	teamId: number;
	color: string;
	x: number;
	y: number;
	lastPositionAt: number;
	alive: boolean;
	score: number;
}

interface MatchVars {
	matchId: string;
	mode: Mode;
	capacity: number;
	tick: number;
	phase: "waiting" | "live" | "finished";
	players: Record<string, PlayerEntry>;
	winnerTeam: number | null;
	winnerPlayerId: string | null;
	// `onWake` stores its loop here so `onSleep` can wait for it.
	loop: Promise<void> | null;
}

interface AssignedPlayer {
	playerId: string;
	teamId: number;
}

type MatchInfoRow = {
	match_id: string;
	mode: Mode;
	capacity: number;
	tick: number;
	phase: MatchVars["phase"];
	winner_team: number | null;
	winner_player_id: string | null;
};

type RosterRow = {
	player_id: string;
	team_id: number;
	x: number;
	y: number;
	score: number;
};

export const arenaMatch = actor({
	options: { name: "Arena - Match", icon: "crosshairs" },
	events: {
		snapshot: event<Snapshot>(),
		shoot: event<ShootEvent>(),
	},
	db: db({
		onMigrate: async (db) => {
			await db.execute(`
				CREATE TABLE IF NOT EXISTS match_info (
					id INTEGER PRIMARY KEY CHECK (id = 1),
					match_id TEXT NOT NULL,
					mode TEXT NOT NULL,
					capacity INTEGER NOT NULL,
					tick INTEGER NOT NULL DEFAULT 0,
					phase TEXT NOT NULL DEFAULT 'waiting',
					winner_team INTEGER,
					winner_player_id TEXT
				)
			`);
			await db.execute(`
				CREATE TABLE IF NOT EXISTS roster (
					player_id TEXT PRIMARY KEY,
					team_id INTEGER NOT NULL,
					x REAL NOT NULL,
					y REAL NOT NULL,
					score INTEGER NOT NULL DEFAULT 0
				)
			`);
		},
	}),
	onCreate: async (
		c,
		input: {
			matchId: string;
			mode: Mode;
			capacity: number;
			assignedPlayers: AssignedPlayer[];
		},
	) => {
		// Save the settings and the roster once, with a random spawn point for
		// each player, so every start of this match sees the same players.
		await c.db.transaction(
			async (tx) => {
				await tx.execute(
					"INSERT INTO match_info (id, match_id, mode, capacity) VALUES (1, ?, ?, ?)",
					input.matchId,
					input.mode,
					input.capacity,
				);
				for (const ap of input.assignedPlayers) {
					await tx.execute(
						"INSERT INTO roster (player_id, team_id, x, y) VALUES (?, ?, ?, ?)",
						ap.playerId,
						ap.teamId,
						Math.random() * WORLD_SIZE,
						Math.random() * WORLD_SIZE,
					);
				}
			},
			{ name: "create-match" },
		);
	},
	createVars: async (c): Promise<MatchVars> => {
		const [info] = await c.db.execute<MatchInfoRow>(
			"SELECT match_id, mode, capacity, tick, phase, winner_team, winner_player_id FROM match_info WHERE id = 1",
		);
		const roster = await c.db.execute<RosterRow>(
			"SELECT player_id, team_id, x, y, score FROM roster",
		);

		// The live match runs in memory and starts from the last save. No
		// connection survives a restart, so every player starts unbound.
		const players: Record<string, PlayerEntry> = {};
		for (const row of roster) {
			players[row.player_id] = {
				connId: null,
				teamId: row.team_id,
				color: getPlayerColor(row.player_id),
				x: row.x,
				y: row.y,
				lastPositionAt: Date.now(),
				alive: true,
				score: row.score,
			};
		}
		return {
			matchId: info!.match_id,
			mode: info!.mode,
			capacity: info!.capacity,
			tick: info!.tick,
			phase: info!.phase,
			players,
			winnerTeam: info!.winner_team,
			winnerPlayerId: info!.winner_player_id,
			loop: null,
		};
	},
	onConnect: (c, conn) => {
		const playerId = (conn.params as { playerId?: string })?.playerId;
		if (!playerId) return;
		const player = c.vars.players[playerId];
		if (!player) {
			conn.disconnect("invalid_player");
			return;
		}
		player.connId = conn.id;

		if (c.vars.phase === "waiting") {
			const allConnected = Object.values(c.vars.players).every(
				(p) => p.connId !== null,
			);
			if (allConnected) {
				c.vars.phase = "live";
			}
		}

		broadcastSnapshot(c);
	},
	onDestroy: async (c) => {
		// `c.abortSignal` also fires on destroy. Let the loop finish its last
		// save before the database closes.
		await c.vars.loop;
		const client = c.client<typeof registry>();
		await client.arenaMatchmaker
			.getOrCreate(["main"])
			.matchCompleted({ matchId: c.vars.matchId });
	},
	onDisconnect: (c, conn) => {
		const found = findPlayerByConnId(c.vars, conn.id);
		if (!found) return;
		const [, player] = found;
		player.connId = null;
		broadcastSnapshot(c);
	},
	// `onWake` runs on every start, so the loop restarts after a sleep or crash
	onWake: (c) => {
		c.vars.loop = runMatch(c).catch((error) => {
			c.log.error({ msg: "match loop failed", error });
		});
	},
	// `c.abortSignal` fires on shutdown, so the loop saves and exits on its own
	onSleep: async (c) => {
		await c.vars.loop;
	},
	actions: {
		updatePosition: (c, input: { x: number; y: number }) => {
			const found = findPlayerByConnId(c.vars, c.conn.id);
			if (!found) {
				throw new UserError("player not found", {
					code: "player_not_found",
				});
			}
			const [, player] = found;
			if (!player.alive) {
				throw new UserError("player is not alive", {
					code: "not_alive",
				});
			}

			const now = Date.now();
			const elapsed = Math.max(0, (now - player.lastPositionAt) / 1000);
			const maxDist = MAX_SPEED * elapsed;

			const dx = input.x - player.x;
			const dy = input.y - player.y;
			const dist = Math.sqrt(dx * dx + dy * dy);

			let newX: number;
			let newY: number;
			if (dist > maxDist && dist > 0) {
				newX = player.x + (dx / dist) * maxDist;
				newY = player.y + (dy / dist) * maxDist;
			} else {
				newX = input.x;
				newY = input.y;
			}

			player.x = Math.max(0, Math.min(WORLD_SIZE, newX));
			player.y = Math.max(0, Math.min(WORLD_SIZE, newY));
			player.lastPositionAt = now;
		},
		shoot: (c, input: { dirX: number; dirY: number }) => {
			const found = findPlayerByConnId(c.vars, c.conn.id);
			if (!found) {
				throw new UserError("player not found", {
					code: "player_not_found",
				});
			}
			const [shooterId, shooter] = found;
			if (!shooter.alive) {
				throw new UserError("player is not alive", {
					code: "not_alive",
				});
			}
			if (c.vars.phase !== "live") {
				throw new UserError("match is not live", { code: "not_live" });
			}

			const mag = Math.sqrt(
				input.dirX * input.dirX + input.dirY * input.dirY,
			);
			if (mag === 0) return;
			const ndx = input.dirX / mag;
			const ndy = input.dirY / mag;

			let closestId: string | null = null;
			let closestDist = Infinity;

			for (const [targetId, target] of Object.entries(c.vars.players)) {
				if (targetId === shooterId) continue;
				if (!target.alive) continue;
				if (shooter.teamId >= 0 && target.teamId === shooter.teamId)
					continue;

				const tx = target.x - shooter.x;
				const ty = target.y - shooter.y;
				const targetDist = Math.sqrt(tx * tx + ty * ty);
				if (targetDist > SHOOT_RANGE || targetDist === 0) continue;

				const dot = (tx / targetDist) * ndx + (ty / targetDist) * ndy;
				const angle = Math.acos(Math.max(-1, Math.min(1, dot)));
				if (angle > SHOOT_ANGLE) continue;

				if (targetDist < closestDist) {
					closestDist = targetDist;
					closestId = targetId;
				}
			}

			if (closestId) {
				const victim = c.vars.players[closestId]!;
				shooter.score += 1;
				victim.x = Math.random() * WORLD_SIZE;
				victim.y = Math.random() * WORLD_SIZE;
				victim.lastPositionAt = Date.now();
			}

			const shootEvent: ShootEvent = {
				shooterId,
				fromX: shooter.x,
				fromY: shooter.y,
				dirX: ndx,
				dirY: ndy,
				hitPlayerId: closestId,
			};
			c.broadcast("shoot", shootEvent);

			checkWinCondition(c);
			if ((c.vars.phase as string) === "finished") {
				broadcastSnapshot(c);
			}
		},
		getSnapshot: (c) => buildSnapshot(c),
	},
});

function checkWinCondition(c: ActorContextOf<typeof arenaMatch>) {
	if (c.vars.phase !== "live") return;

	if (c.vars.mode === "ffa") {
		for (const [id, player] of Object.entries(c.vars.players)) {
			if (player.score >= SCORE_LIMIT) {
				c.vars.phase = "finished";
				c.vars.winnerPlayerId = id;
				return;
			}
		}
	} else {
		const teamScores: Record<number, number> = {};
		for (const player of Object.values(c.vars.players)) {
			teamScores[player.teamId] =
				(teamScores[player.teamId] ?? 0) + player.score;
		}
		for (const [teamId, score] of Object.entries(teamScores)) {
			if (score >= SCORE_LIMIT) {
				c.vars.phase = "finished";
				c.vars.winnerTeam = Number(teamId);
				return;
			}
		}
	}
}

function broadcastSnapshot(c: ActorContextOf<typeof arenaMatch>) {
	c.broadcast("snapshot", buildSnapshot(c));
}

/** Steps the match every `TICK_MS` until the actor starts shutting down. */
async function runMatch(c: ActorContextOf<typeof arenaMatch>): Promise<void> {
	const tick = interval(TICK_MS);
	let savedAt = Date.now();
	while (!c.aborted) {
		await tick();
		if (c.aborted) break;

		// Ticks and actions only change memory. Save to SQLite every
		// SNAPSHOT_INTERVAL_MS in every phase, so a finished match stays
		// finished after a crash.
		const now = Date.now();
		if (now - savedAt >= SNAPSHOT_INTERVAL_MS) {
			await saveMatch(c);
			savedAt = now;
		}

		if (c.vars.phase !== "live") continue;

		c.vars.tick += 1;

		checkWinCondition(c);

		broadcastSnapshot(c);
	}

	// Save once more when the Actor stops, so a clean restart loses nothing.
	await saveMatch(c);
}

/**
 * Writes the phase, tick, winner, and every player's position and score to
 * SQLite in one transaction, so a crash leaves either the previous save or
 * this one, never part of both.
 */
async function saveMatch(c: ActorContextOf<typeof arenaMatch>) {
	// Copy the values first, because actions such as updatePosition can run
	// while the transaction waits on SQLite.
	const { tick, phase, winnerTeam, winnerPlayerId } = c.vars;
	const players = Object.entries(c.vars.players).map(
		([playerId, player]) => ({
			playerId,
			x: player.x,
			y: player.y,
			score: player.score,
		}),
	);

	await c.db.transaction(
		async (tx) => {
			await tx.execute(
				"UPDATE match_info SET tick = ?, phase = ?, winner_team = ?, winner_player_id = ? WHERE id = 1",
				tick,
				phase,
				winnerTeam,
				winnerPlayerId,
			);
			for (const player of players) {
				await tx.execute(
					"UPDATE roster SET x = ?, y = ?, score = ? WHERE player_id = ?",
					player.x,
					player.y,
					player.score,
					player.playerId,
				);
			}
		},
		{ name: "save-match" },
	);
}

interface Snapshot {
	matchId: string;
	mode: Mode;
	capacity: number;
	tick: number;
	phase: "waiting" | "live" | "finished";
	winnerTeam: number | null;
	winnerPlayerId: string | null;
	worldSize: number;
	scoreLimit: number;
	players: Record<
		string,
		{ x: number; y: number; teamId: number; color: string; score: number }
	>;
}

interface ShootEvent {
	shooterId: string;
	fromX: number;
	fromY: number;
	dirX: number;
	dirY: number;
	hitPlayerId: string | null;
}

function buildSnapshot(c: ActorContextOf<typeof arenaMatch>): Snapshot {
	const players: Record<
		string,
		{ x: number; y: number; teamId: number; color: string; score: number }
	> = {};
	for (const [id, entry] of Object.entries(c.vars.players)) {
		players[id] = {
			x: entry.x,
			y: entry.y,
			teamId: entry.teamId,
			color: entry.color,
			score: entry.score,
		};
	}
	return {
		matchId: c.vars.matchId,
		mode: c.vars.mode,
		capacity: c.vars.capacity,
		tick: c.vars.tick,
		phase: c.vars.phase,
		winnerTeam: c.vars.winnerTeam,
		winnerPlayerId: c.vars.winnerPlayerId,
		worldSize: WORLD_SIZE,
		scoreLimit: SCORE_LIMIT,
		players,
	};
}

function findPlayerByConnId(
	vars: MatchVars,
	connId: string,
): [string, PlayerEntry] | null {
	for (const [id, entry] of Object.entries(vars.players)) {
		if (entry.connId === connId) return [id, entry];
	}
	return null;
}
  • onWake starts runMatch as a background task on every start, and onSleep waits for it to save and exit. Connected players keep the match awake. Once everyone leaves, it sleeps, and the next connection resumes it from the last save.
  • interval(TICK_MS) ticks every 50 ms, twice as often as the battle royale, because arena fights are short and close. Ticks are skipped until the match is live. Each live tick checks the win condition and broadcasts one snapshot.
  • Ticks and actions only change memory. Every SNAPSHOT_INTERVAL_MS (1 second) in every phase, and once more when the Actor stops, saveMatch writes the phase, tick, winner, positions, and scores in one c.db.transaction. A crash loses at most about a second of play, and a finished match stays finished.
  • The client owns movement. updatePosition limits each move to MAX_SPEED times the time since the last update and keeps the player inside the world, so a client that teleports only moves as far as it could have run.
  • The server owns combat. shoot skips teammates, finds the closest enemy within SHOOT_RANGE and SHOOT_ANGLE of the aim, gives the shooter a point, respawns the victim at a random spot, and broadcasts shoot.
  • checkWinCondition ends a free-for-all when one player reaches SCORE_LIMIT, and a team match when a team’s combined score does. When the match Actor is destroyed, onDestroy calls matchCompleted so the matchmaker deletes its row.

For a Rocket League style match, the ball is shared by both teams, so simulate it on the server with @dimforge/rapier3d and let each client predict only its own car. Also limit how often each connection may call updatePosition and shoot. The example client sends positions at most 20 times per second.

Register the Actors

Add both Actors to the registry and start it:

The example registers every game type in one registry. An arena game only needs arenaMatchmaker and arenaMatch. Each Actor file imports type { registry } so that c.client<typeof registry>() is typed when the matchmaker creates a match and the match reports back.

Connect players

The menu queues for a mode and waits for an assignment, then the game connects to the match:

  • The menu keeps its matchmaker connection open while queued, because the assignment arrives as an event. queueUpdate drives the “players in queue” counter.
  • waitForAssignment listens for assignmentReady with this player’s id and also calls getAssignment once, in case the event fired before the listener was attached. getAssignment only returns a row whose conn_id matches the caller’s connection.
  • The game connects with arenaMatch.get(...) and playerId in params, the same id the matchmaker put on the roster.
  • The client moves its own player every frame and sends updatePosition at most every 50 ms. If a snapshot puts it more than 40 pixels from where it thinks it is, it snaps to the server’s position.

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, pick Arena, choose FFA, and click Queue. The counter shows 1/4 players in queue. Click Add Bot three times. The menu shows Match Found!, then the match goes live once all four players connect. Move with WASD and click to shoot. The first player to 10 kills wins. Pick Duo or Squad to see teams.

Next steps

  • Ranked Game: pair players by rating instead of first come, first served.
  • Transactions: how the matchmaker claims players when requests overlap.
  • Events: broadcast to everyone or send to one connection.
Edit this page Last updated November 10, 2026