# Arena Game

The game client calls queueForMatch on the arenaMatchmaker Actor with a mode, and the matchmaker adds the player to its player_pool table. Once enough players are queued for that mode, the matchmaker creates an arenaMatch Actor with the roster and teams, saves the assignments, and sends the assignmentReady event to the client. The client connects to the match with its playerId, then sends updatePosition and shoot. The match broadcasts snapshot and shoot events back. When the match is destroyed, it sends matchCompleted to the matchmaker. The numbers show the order of the calls. There is one matchmaker and one arenaMatch Actor per match.

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

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

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

matchmaker.ts:

```ts
/*
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.

> **WARNING:** `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:

match.ts:

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

match.ts:

```ts
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.

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

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

menu.tsx:

```tsx
const queueForMatch = async () => {
	setStatus("queuing");
	setError("");
	abortRef.current = false;
	matchedRef.current = false;
	try {
		const mm = client.arenaMatchmaker.getOrCreate(["main"]).connect();
		mmRef.current = mm;

		let myPlayerId: string | null = null;
		let matched = false;

		const resolveMatch = (assignment: ArenaMatchInfo) => {
			if (matched || abortRef.current) return;
			matched = true;
			matchedRef.current = true;
			setStatus("matched");
			setTimeout(async () => {
				// Don't destroy bots - they need to connect to the match.
				await cleanup(false);
				onReady(assignment);
			}, 1500);
		};

		mm.on("queueUpdate", (data) => {
			setQueueCount(data.counts[mode] ?? 0);
		});

		setStatus("queued");

		const response = await mm.queueForMatch({
			mode,
		});
		if (!response?.playerId || abortRef.current)
			throw new Error("Failed to queue");
		myPlayerId = response.playerId;

		const sizes = await mm.getQueueSizes();
		if (!abortRef.current) setQueueCount(sizes[mode] ?? 0);

		const assignment = await waitForAssignment<ArenaMatchInfo>(
			mm,
			myPlayerId,
		);
		resolveMatch(assignment);
	} catch (err) {
		if (abortRef.current) return;
		await cleanup();
		setError(err instanceof Error ? err.message : String(err));
		setStatus("error");
	}
};
```

wait-for-assignment.ts:

```ts
interface Assignment {
	playerId: string;
}

type AssignmentConnection = {
	getAssignment: (input: { playerId: string }) => Promise<Assignment | null>;
	on: (
		event: "assignmentReady",
		handler: (assignment: Assignment) => void,
	) => (() => void) | undefined;
};

function normalizeError(err: unknown): Error {
	if (err instanceof Error) return err;
	return new Error(String(err));
}

export async function waitForAssignment<T extends Assignment>(
	mm: AssignmentConnection,
	playerId: string,
	timeoutMs = 120_000,
): Promise<T> {
	const existing = await readAssignment<T>(mm, playerId);
	if (existing) return existing;

	return await new Promise<T>((resolve, reject) => {
		let settled = false;
		let timeout: number | null = null;
		let off: (() => void) | undefined;

		const settle = (fn: () => void) => {
			if (settled) return;
			settled = true;
			if (timeout !== null) window.clearTimeout(timeout);
			off?.();
			fn();
		};

		off = mm.on("assignmentReady", (next) => {
			if (next.playerId !== playerId) return;
			settle(() => resolve(next as T));
		});

		timeout = window.setTimeout(() => {
			settle(() => reject(new Error("Timed out waiting for assignment")));
		}, timeoutMs);

		void readAssignment<T>(mm, playerId)
			.then((next) => {
				if (!next) return;
				settle(() => resolve(next));
			})
			.catch((err) => {
				settle(() => reject(normalizeError(err)));
			});
	});
}

async function readAssignment<T extends Assignment>(
	mm: AssignmentConnection,
	playerId: string,
): Promise<T | null> {
	return (await mm.getAssignment({ playerId })) as T | null;
}
```

arena-game.ts:

```ts
import type { ArenaMatchConn } from "../../actor-types.ts";
import type { GameClient } from "../../client.ts";
import type { ArenaMatchInfo } from "./menu.tsx";

const PLAYER_RADIUS = 12;
const LERP_FACTOR = 0.2;
const GRID_SPACING = 50;
const SEND_RATE_MS = 50; // ~20Hz position updates
const MOVE_SPEED = 200; // pixels per second (matches server MAX_SPEED)
const SHOT_LINE_DURATION = 150; // ms to show shot line
const RUBBER_BAND_THRESHOLD = 40; // pixels before snapping local player to server

interface ShotLine {
	fromX: number;
	fromY: number;
	toX: number;
	toY: number;
	hit: boolean;
	createdAt: number;
}

export class ArenaGame {
	private stopped = false;
	private rafId = 0;
	private worldSize = 600;
	private targets: Record<
		string,
		{ x: number; y: number; teamId: number; color: string; score: number }
	> = {};
	private display: Record<string, { x: number; y: number }> = {};
	private keys: Record<string, boolean> = {};
	private phase: "waiting" | "live" | "finished" = "waiting";
	private scoreLimit = 10;
	private winnerTeam: number | null = null;
	private winnerPlayerId: string | null = null;
	private shotLines: ShotLine[] = [];
	private lastSendTime = 0;
	private localX = 300;
	private localY = 300;
	private lastFrameTime = 0;
	private botInterval = 0;
	private conn: ArenaMatchConn;

	constructor(
		private canvas: HTMLCanvasElement | null,
		client: GameClient,
		private matchInfo: ArenaMatchInfo,
		private options: { bot?: boolean } = {},
	) {
		this.conn = client.arenaMatch
			.get([matchInfo.matchId], {
				params: { playerId: matchInfo.playerId },
			})
			.connect();

		this.conn.on("snapshot", (snap) => {
			this.worldSize = snap.worldSize;
			this.scoreLimit = snap.scoreLimit;
			this.phase = snap.phase;
			this.winnerTeam = snap.winnerTeam;
			this.winnerPlayerId = snap.winnerPlayerId;

			for (const [id, data] of Object.entries(snap.players)) {
				this.targets[id] = data;
				if (id === this.matchInfo.playerId) {
					// Rubber-band local player if server disagrees significantly.
					const dx = data.x - this.localX;
					const dy = data.y - this.localY;
					if (Math.sqrt(dx * dx + dy * dy) > RUBBER_BAND_THRESHOLD) {
						this.localX = data.x;
						this.localY = data.y;
					}
				} else if (!this.display[id]) {
					this.display[id] = { x: data.x, y: data.y };
				}
			}
			// Remove players no longer in snapshot.
			for (const id of Object.keys(this.targets)) {
				if (!snap.players[id]) {
					delete this.targets[id];
					delete this.display[id];
				}
			}
		});

		this.conn.on("shoot", (shot) => {
			const lineLen = 300;
			this.shotLines.push({
				fromX: shot.fromX,
				fromY: shot.fromY,
				toX: shot.fromX + shot.dirX * lineLen,
				toY: shot.fromY + shot.dirY * lineLen,
				hit: shot.hitPlayerId !== null,
				createdAt: performance.now(),
			});
		});

		if (options.bot) {
			this.botInterval = window.setInterval(() => {
				if (this.phase !== "live") return;
				this.localX += (Math.random() - 0.5) * 40;
				this.localY += (Math.random() - 0.5) * 40;
				this.localX = Math.max(
					0,
					Math.min(this.worldSize, this.localX),
				);
				this.localY = Math.max(
					0,
					Math.min(this.worldSize, this.localY),
				);
				this.conn
					.updatePosition({ x: this.localX, y: this.localY })
					.catch(() => {});
				if (Math.random() < 0.2) {
					const angle = Math.random() * Math.PI * 2;
					this.conn
						.shoot({ dirX: Math.cos(angle), dirY: Math.sin(angle) })
						.catch(() => {});
				}
			}, 100);
		} else if (canvas) {
			canvas.addEventListener("click", this.onClick);
			window.addEventListener("keydown", this.onKeyDown);
			window.addEventListener("keyup", this.onKeyUp);
			this.lastFrameTime = performance.now();
			this.rafId = requestAnimationFrame(this.draw);
		}
	}

	destroy() {
		this.stopped = true;
		cancelAnimationFrame(this.rafId);
		clearInterval(this.botInterval);
		if (!this.options.bot && this.canvas) {
			this.canvas.removeEventListener("click", this.onClick);
			window.removeEventListener("keydown", this.onKeyDown);
			window.removeEventListener("keyup", this.onKeyUp);
		}
		this.conn.dispose().catch(() => {});
	}

	private onKeyDown = (e: KeyboardEvent) => {
		this.keys[e.key] = true;
	};

	private onKeyUp = (e: KeyboardEvent) => {
		this.keys[e.key] = false;
	};

	private onClick = (e: MouseEvent) => {
		if (this.phase !== "live" || !this.canvas) return;
		const rect = this.canvas.getBoundingClientRect();
		const sx = this.canvas.width / this.worldSize;
		const sy = this.canvas.height / this.worldSize;
		const clickX = (e.clientX - rect.left) / sx;
		const clickY = (e.clientY - rect.top) / sy;
		const dx = clickX - this.localX;
		const dy = clickY - this.localY;
		const mag = Math.sqrt(dx * dx + dy * dy);
		if (mag === 0) return;
		this.conn.shoot({ dirX: dx / mag, dirY: dy / mag }).catch(() => {});
	};

	private draw = () => {
		if (this.stopped) return;
		const now = performance.now();
		const dt = (now - this.lastFrameTime) / 1000;
		this.lastFrameTime = now;
		const canvas = this.canvas!;
		const ctx = canvas.getContext("2d")!;
		const sx = canvas.width / this.worldSize;
		const sy = canvas.height / this.worldSize;

		// Client-side movement.
		if (this.phase === "live") {
			let ix = 0;
			let iy = 0;
			if (this.keys.w || this.keys.W || this.keys.ArrowUp) iy -= 1;
			if (this.keys.s || this.keys.S || this.keys.ArrowDown) iy += 1;
			if (this.keys.a || this.keys.A || this.keys.ArrowLeft) ix -= 1;
			if (this.keys.d || this.keys.D || this.keys.ArrowRight) ix += 1;
			if (ix !== 0 || iy !== 0) {
				const mag = Math.sqrt(ix * ix + iy * iy);
				this.localX = Math.max(
					0,
					Math.min(
						this.worldSize,
						this.localX + (ix / mag) * MOVE_SPEED * dt,
					),
				);
				this.localY = Math.max(
					0,
					Math.min(
						this.worldSize,
						this.localY + (iy / mag) * MOVE_SPEED * dt,
					),
				);
			}

			// Send position to server at capped rate.
			if (now - this.lastSendTime >= SEND_RATE_MS) {
				this.lastSendTime = now;
				this.conn
					.updatePosition({ x: this.localX, y: this.localY })
					.catch(() => {});
			}
		}

		// Clear.
		ctx.fillStyle = "#000000";
		ctx.fillRect(0, 0, canvas.width, canvas.height);

		// Grid.
		ctx.strokeStyle = "#2c2c2e";
		ctx.lineWidth = 1;
		ctx.beginPath();
		for (let x = 0; x <= this.worldSize; x += GRID_SPACING) {
			const px = x * sx;
			ctx.moveTo(px, 0);
			ctx.lineTo(px, canvas.height);
		}
		for (let y = 0; y <= this.worldSize; y += GRID_SPACING) {
			const py = y * sy;
			ctx.moveTo(0, py);
			ctx.lineTo(canvas.width, py);
		}
		ctx.stroke();

		// Shot lines.
		this.shotLines = this.shotLines.filter(
			(s) => now - s.createdAt < SHOT_LINE_DURATION,
		);
		for (const shot of this.shotLines) {
			const alpha = 1 - (now - shot.createdAt) / SHOT_LINE_DURATION;
			ctx.strokeStyle = shot.hit
				? `rgba(255, 59, 48, ${alpha})`
				: `rgba(255, 255, 255, ${alpha * 0.4})`;
			ctx.lineWidth = 2;
			ctx.beginPath();
			ctx.moveTo(shot.fromX * sx, shot.fromY * sy);
			ctx.lineTo(shot.toX * sx, shot.toY * sy);
			ctx.stroke();
		}

		// Players.
		for (const [id, target] of Object.entries(this.targets)) {
			const isMe = id === this.matchInfo.playerId;
			let px: number;
			let py: number;

			if (isMe) {
				px = this.localX * sx;
				py = this.localY * sy;
			} else {
				const d = this.display[id];
				if (!d) continue;
				d.x += (target.x - d.x) * LERP_FACTOR;
				d.y += (target.y - d.y) * LERP_FACTOR;
				px = d.x * sx;
				py = d.y * sy;
			}

			const color = target.color;

			ctx.beginPath();
			ctx.arc(px, py, PLAYER_RADIUS, 0, Math.PI * 2);
			ctx.fillStyle = color;
			ctx.fill();
			if (isMe) {
				ctx.lineWidth = 2;
				ctx.strokeStyle = "#ffffff";
				ctx.stroke();
			}

			// Label: short ID + score.
			ctx.fillStyle = "#ffffff";
			ctx.font = "11px sans-serif";
			ctx.textAlign = "center";
			ctx.fillText(
				`${id.slice(0, 6)} [${target.score}/${this.scoreLimit}]`,
				px,
				py - PLAYER_RADIUS - 4,
			);
		}

		// Score overlay.
		ctx.fillStyle = "rgba(0, 0, 0, 0.6)";
		ctx.fillRect(0, 0, canvas.width, 28);
		ctx.fillStyle = "#ffffff";
		ctx.font = "12px sans-serif";
		ctx.textAlign = "left";
		const myData = this.targets[this.matchInfo.playerId];
		const scoreText = myData
			? `Score: ${myData.score}/${this.scoreLimit}`
			: "";
		ctx.fillText(`${this.phase.toUpperCase()}  ${scoreText}`, 8, 18);

		// Phase overlay.
		if (this.phase === "waiting") {
			ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
			ctx.fillRect(0, 0, canvas.width, canvas.height);
			ctx.fillStyle = "#ffffff";
			ctx.font = "24px sans-serif";
			ctx.textAlign = "center";
			ctx.fillText(
				"Waiting for players...",
				canvas.width / 2,
				canvas.height / 2,
			);
		} else if (this.phase === "finished") {
			ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
			ctx.fillRect(0, 0, canvas.width, canvas.height);
			ctx.fillStyle = "#ffffff";
			ctx.font = "24px sans-serif";
			ctx.textAlign = "center";
			let winText = "Match Over";
			if (this.winnerPlayerId) {
				winText =
					this.winnerPlayerId === this.matchInfo.playerId
						? "You Win!"
						: `Winner: ${this.winnerPlayerId.slice(0, 8)}`;
			} else if (this.winnerTeam !== null) {
				winText =
					this.winnerTeam === this.matchInfo.teamId
						? "Your Team Wins!"
						: `Team ${this.winnerTeam} Wins!`;
			}
			ctx.fillText(winText, canvas.width / 2, canvas.height / 2);
		}

		this.rafId = requestAnimationFrame(this.draw);
	};
}
```

- 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

```sh
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](/guides/ranked-game): pair players by rating instead of first come, first served.
- [Queues](/actors/docs/queues): how the matchmaker handles one request at a time.
- [Events](/actors/docs/events): broadcast to everyone or send to one connection.
