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, queue } 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;
};

export const arenaMatchmaker = actor({
	options: { name: "Arena - Matchmaker", icon: "crosshairs" },
	db: db({
		onMigrate: migrateTables,
	}),
	queues: {
		queueForMatch: queue<{
			mode: Mode;
			playerId: string;
			connId: string;
		}>(),
		unqueueForMatch: queue<{ connId: string }>(),
		matchCompleted: queue<{ matchId: string }>(),
	},
	events: {
		assignmentReady: event<ArenaAssignment>(),
		queueUpdate: event<{ counts: Record<string, number> }>(),
	},
	actions: {
		queueForMatch: async (c, { mode }: { mode: Mode }) => {
			const playerId = crypto.randomUUID();
			await c.queue.send("queueForMatch", {
				mode,
				playerId,
				connId: c.conn.id,
			});
			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,
			};
		},
	},
	onDisconnect: async (c, conn) => {
		await c.queue.send("unqueueForMatch", { connId: conn.id });
	},
	run: async (c) => {
		for await (const message of c.queue.iter()) {
			if (message.name === "queueForMatch") {
				await processQueueEntry(
					c,
					message.body.mode,
					message.body.playerId,
					message.body.connId,
				);
			} else if (message.name === "unqueueForMatch") {
				await c.db.execute(
					`DELETE FROM player_pool WHERE conn_id = ?`,
					message.body.connId,
				);
				await broadcastQueueSizes(c);
			} else if (message.name === "matchCompleted") {
				await c.db.execute(
					`DELETE FROM matches WHERE match_id = ?`,
					message.body.matchId,
				);
			}
		}
	},
});

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 processQueueEntry(
	c: ActorContextOf<typeof arenaMatchmaker>,
	mode: Mode,
	playerId: string,
	connId: string,
): Promise<void> {
	const config = MODE_CONFIG[mode];

	await c.db.execute(
		`INSERT OR REPLACE INTO player_pool (player_id, mode, queued_at, conn_id) VALUES (?, ?, ?, ?)`,
		playerId,
		mode,
		Date.now(),
		connId,
	);

	await broadcastQueueSizes(c);

	const countRows = await c.db.execute<{ cnt: number }>(
		`SELECT COUNT(*) as cnt FROM player_pool WHERE mode = ?`,
		mode,
	);
	const count = countRows[0]?.cnt ?? 0;

	if (count >= config.capacity) {
		await fillMatch(c, mode, config);
	}
}

async function fillMatch(
	c: ActorContextOf<typeof arenaMatchmaker>,
	mode: Mode,
	config: { capacity: number; teams: number },
) {
	const queued = await c.db.execute<QueuePlayerRow>(
		`SELECT player_id, conn_id FROM player_pool WHERE mode = ? ORDER BY queued_at ASC LIMIT ?`,
		mode,
		config.capacity,
	);

	const queuedPlayers = queued.map((r) => ({
		playerId: r.player_id,
		connId: r.conn_id,
	}));
	const playerIds = queuedPlayers.map((r) => r.playerId);

	for (const pid of playerIds) {
		await c.db.execute(`DELETE FROM player_pool WHERE player_id = ?`, pid);
	}

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

	const client = c.client<typeof registry>();
	await client.arenaMatch.create([matchId], {
		input: {
			matchId,
			mode,
			capacity: config.capacity,
			assignedPlayers: assignedPlayers.map((ap) => ({
				playerId: ap.playerId,
				teamId: ap.teamId,
			})),
		},
	});

	await c.db.execute(
		`INSERT INTO matches (match_id, mode, capacity, created_at) VALUES (?, ?, ?, ?)`,
		matchId,
		mode,
		config.capacity,
		Date.now(),
	);

	await broadcastQueueSizes(c);

	for (const ap of assignedPlayers) {
		await c.db.execute(
			`INSERT INTO assignments (player_id, match_id, team_id, mode, conn_id) VALUES (?, ?, ?, ?, ?)`,
			ap.playerId,
			matchId,
			ap.teamId,
			mode,
			ap.connId,
		);
		c.broadcast("assignmentReady", {
			matchId,
			playerId: ap.playerId,
			teamId: ap.teamId,
			mode,
			connId: ap.connId,
		} satisfies ArenaAssignment);
	}
}

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, records c.conn.id, and puts a message on the internal queueForMatch queue. It returns right away. The match arrives later as an event, because nobody knows how long the wait will be.
  • run handles one queue message at a time, so two players joining at once can’t both fill the last slot. Each entry goes into player_pool, the new counts go out as queueUpdate, and fillMatch runs once the mode reaches its MODE_CONFIG capacity.
  • fillMatch takes the players who have waited longest and assigns teams by position (idx % teams, or -1 for free-for-all). It creates the arenaMatch Actor with the roster as input, saves each row in assignments, and broadcasts assignmentReady.
  • onDisconnect sends unqueueForMatch, 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:

createState: (
	_c,
	input: {
		matchId: string;
		mode: Mode;
		capacity: number;
		assignedPlayers: AssignedPlayer[];
	},
): State => {
	const players: Record<string, PlayerEntry> = {};
	for (const ap of input.assignedPlayers) {
		players[ap.playerId] = {
			connId: null,
			teamId: ap.teamId,
			color: getPlayerColor(ap.playerId),
			x: Math.random() * WORLD_SIZE,
			y: Math.random() * WORLD_SIZE,
			lastPositionAt: Date.now(),
			alive: true,
			score: 0,
		};
	}
	return {
		matchId: input.matchId,
		mode: input.mode,
		capacity: input.capacity,
		tick: 0,
		phase: "waiting",
		players,
		winnerTeam: null,
		winnerPlayerId: 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;

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

	broadcastSnapshot(c);
},
  • createState seeds every assigned player from the input with their team, color, and a random spawn point. 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 { 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,
	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 State {
	matchId: string;
	mode: Mode;
	capacity: number;
	tick: number;
	phase: "waiting" | "live" | "finished";
	players: Record<string, PlayerEntry>;
	winnerTeam: number | null;
	winnerPlayerId: string | null;
}

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

export const arenaMatch = actor({
	options: { name: "Arena - Match", icon: "crosshairs" },
	events: {
		snapshot: event<Snapshot>(),
		shoot: event<ShootEvent>(),
	},
	createState: (
		_c,
		input: {
			matchId: string;
			mode: Mode;
			capacity: number;
			assignedPlayers: AssignedPlayer[];
		},
	): State => {
		const players: Record<string, PlayerEntry> = {};
		for (const ap of input.assignedPlayers) {
			players[ap.playerId] = {
				connId: null,
				teamId: ap.teamId,
				color: getPlayerColor(ap.playerId),
				x: Math.random() * WORLD_SIZE,
				y: Math.random() * WORLD_SIZE,
				lastPositionAt: Date.now(),
				alive: true,
				score: 0,
			};
		}
		return {
			matchId: input.matchId,
			mode: input.mode,
			capacity: input.capacity,
			tick: 0,
			phase: "waiting",
			players,
			winnerTeam: null,
			winnerPlayerId: 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;

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

		broadcastSnapshot(c);
	},
	onDestroy: async (c) => {
		const client = c.client<typeof registry>();
		await client.arenaMatchmaker
			.getOrCreate(["main"])
			.send("matchCompleted", { matchId: c.state.matchId });
	},
	onDisconnect: (c, conn) => {
		const found = findPlayerByConnId(c.state, conn.id);
		if (!found) return;
		const [, player] = found;
		player.connId = null;
		broadcastSnapshot(c);
	},
	run: async (c) => {
		const tick = interval(TICK_MS);
		while (!c.aborted) {
			await tick();
			if (c.aborted) break;

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

			c.state.tick += 1;

			checkWinCondition(c);

			broadcastSnapshot(c);
		}
	},
	actions: {
		updatePosition: (c, input: { x: number; y: number }) => {
			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.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.state, 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.state.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.state.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.state.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.state.phase as string) === "finished") {
				broadcastSnapshot(c);
			}
		},
		getSnapshot: (c) => buildSnapshot(c),
	},
});

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

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

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

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.state.players)) {
		players[id] = {
			x: entry.x,
			y: entry.y,
			teamId: entry.teamId,
			color: entry.color,
			score: entry.score,
		};
	}
	return {
		matchId: c.state.matchId,
		mode: c.state.mode,
		capacity: c.state.capacity,
		tick: c.state.tick,
		phase: c.state.phase,
		winnerTeam: c.state.winnerTeam,
		winnerPlayerId: c.state.winnerPlayerId,
		worldSize: WORLD_SIZE,
		scoreLimit: SCORE_LIMIT,
		players,
	};
}

function findPlayerByConnId(
	state: State,
	connId: string,
): [string, PlayerEntry] | null {
	for (const [id, entry] of Object.entries(state.players)) {
		if (entry.connId === connId) return [id, entry];
	}
	return null;
}
  • 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.
  • 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 sends 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.
  • Queues: how the matchmaker handles one request at a time.
  • Events: broadcast to everyone or send to one connection.