# Battle Royale

The game client sends findMatch to the battleRoyaleMatchmaker Actor, which keeps lobbies and seat reservations in its SQLite tables. If no lobby is open, the matchmaker creates a battleRoyaleMatch Actor. It replies with a matchId and playerId. The client connects to the match with its playerId, and the match claims the seat by sending pendingPlayerConnected to the matchmaker. The client then sends updatePosition and shoot, and the match broadcasts snapshot and shoot events back. The numbers show the order of the calls. There is one matchmaker and one battleRoyaleMatch Actor per match.

What we'll build:

- A `battleRoyaleMatchmaker` Actor that puts each player in the fullest lobby that has not started, and opens a new lobby when none has room.
- A `battleRoyaleMatch` Actor per match that runs the lobby countdown, the shrinking zone, and eliminations on a loop of 10 ticks per second.
- Movement that the client owns and the server checks, and shots that only the server resolves.
- A browser client that finds a match, moves with WASD, and shoots with a click.

### Install

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

### Create the matchmaker

One `battleRoyaleMatchmaker` Actor, keyed `["main"]`, decides which lobby each player joins. It keeps lobbies and seat reservations in its SQLite database:

matchmaker.ts:

```ts
/*
This matchmaker uses a two phase join flow.
1. findMatch claims a slot immediately and returns matchId and playerId.
2. The client connects to the match actor with playerId.
3. The match actor claims that pending player through pendingPlayerConnected before first join.
4. Pending players expire after JOIN_RESERVATION_TTL_MS, which removes never connected players.
5. updateMatch reports occupied player count and started state, while player_count stays pending + occupied.
*/
import { type ActorContextOf, actor, queue } from "rivetkit";
import { db, type RawAccess } from "rivetkit/db";

import type { registry } from "../index.ts";
import { LOBBY_CAPACITY } from "./config.ts";

const JOIN_RESERVATION_TTL_MS = 15_000;

export const battleRoyaleMatchmaker = actor({
	options: { name: "Battle Royale - Matchmaker", icon: "skull-crossbones" },
	db: db({
		onMigrate: migrateTables,
	}),
	queues: {
		findMatch: queue<
			Record<string, never>,
			{ matchId: string; playerId: string }
		>(),
		pendingPlayerConnected: queue<
			{ matchId: string; playerId: string },
			{ accepted: boolean }
		>(),
		updateMatch: queue<{
			matchId: string;
			connectedPlayerCount: number;
			isStarted: boolean;
		}>(),
		closeMatch: queue<{ matchId: string }>(),
	},
	run: async (c) => {
		for await (const message of c.queue.iter({ completable: true })) {
			const now = Date.now();
			await expirePendingPlayers(c, now);

			if (message.name === "findMatch") {
				const result = await processFindMatch(c, now);
				await message.complete(result);
			} else if (message.name === "pendingPlayerConnected") {
				const result = await processPendingPlayerConnected(
					c,
					message.body,
					now,
				);
				await message.complete(result);
			} else if (message.name === "updateMatch") {
				await c.db.execute(
					`UPDATE matches SET connected_player_count = ?, is_started = ?, updated_at = ? WHERE match_id = ?`,
					message.body.connectedPlayerCount,
					message.body.isStarted ? 1 : 0,
					now,
					message.body.matchId,
				);
				await syncClaimedPlayerCount(c, message.body.matchId, now);
				await message.complete();
			} else if (message.name === "closeMatch") {
				await c.db.execute(
					`DELETE FROM pending_players WHERE match_id = ?`,
					message.body.matchId,
				);
				await c.db.execute(
					`DELETE FROM matches WHERE match_id = ?`,
					message.body.matchId,
				);
				await message.complete();
			}
		}
	},
});

async function processFindMatch(
	c: ActorContextOf<typeof battleRoyaleMatchmaker>,
	now: number,
): Promise<{ matchId: string; playerId: string }> {
	const rows = await c.db.execute<{ match_id: string }>(
		`SELECT match_id FROM matches WHERE is_started = 0 AND player_count < ? ORDER BY player_count DESC, created_at ASC LIMIT 1`,
		LOBBY_CAPACITY,
	);
	let matchId = rows[0]?.match_id ?? null;

	if (!matchId) {
		matchId = crypto.randomUUID();
		await c.db.execute(
			`INSERT INTO matches (match_id, player_count, connected_player_count, is_started, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`,
			matchId,
			0,
			0,
			0,
			now,
			now,
		);
		const client = c.client<typeof registry>();
		await client.battleRoyaleMatch.create([matchId], {
			input: { matchId },
		});
	}

	const playerId = crypto.randomUUID();
	const expiresAt = now + JOIN_RESERVATION_TTL_MS;

	await c.db.execute(
		`INSERT INTO pending_players (match_id, player_id, expires_at, created_at) VALUES (?, ?, ?, ?)`,
		matchId,
		playerId,
		expiresAt,
		now,
	);
	await syncClaimedPlayerCount(c, matchId, now);

	return { matchId, playerId };
}

async function processPendingPlayerConnected(
	c: ActorContextOf<typeof battleRoyaleMatchmaker>,
	input: { matchId: string; playerId: string },
	now: number,
): Promise<{ accepted: boolean }> {
	const rows = await c.db.execute<{ expires_at: number }>(
		`SELECT expires_at FROM pending_players WHERE match_id = ? AND player_id = ? LIMIT 1`,
		input.matchId,
		input.playerId,
	);
	const row = rows[0];
	if (!row) {
		return { accepted: false };
	}
	if (row.expires_at <= now) {
		await c.db.execute(
			`DELETE FROM pending_players WHERE match_id = ? AND player_id = ?`,
			input.matchId,
			input.playerId,
		);
		await syncClaimedPlayerCount(c, input.matchId, now);
		return { accepted: false };
	}

	await c.db.execute(
		`DELETE FROM pending_players WHERE match_id = ? AND player_id = ?`,
		input.matchId,
		input.playerId,
	);
	await syncClaimedPlayerCount(c, input.matchId, now);

	return { accepted: true };
}

async function expirePendingPlayers(
	c: ActorContextOf<typeof battleRoyaleMatchmaker>,
	now: number,
): Promise<void> {
	const rows = await c.db.execute<{ match_id: string }>(
		`SELECT DISTINCT match_id FROM pending_players WHERE expires_at <= ?`,
		now,
	);
	if (rows.length === 0) {
		return;
	}

	await c.db.execute(
		`DELETE FROM pending_players WHERE expires_at <= ?`,
		now,
	);

	for (const row of rows) {
		await syncClaimedPlayerCount(c, row.match_id, now);
	}
}

async function syncClaimedPlayerCount(
	c: ActorContextOf<typeof battleRoyaleMatchmaker>,
	matchId: string,
	now: number,
): Promise<void> {
	await c.db.execute(
		`UPDATE matches
			SET player_count = connected_player_count + COALESCE(
				(SELECT COUNT(*) FROM pending_players WHERE match_id = ?),
				0
			),
			updated_at = ?
		WHERE match_id = ?`,
		matchId,
		now,
		matchId,
	);
}

async function migrateTables(dbHandle: RawAccess) {
	await dbHandle.execute(`
		CREATE TABLE IF NOT EXISTS matches (
			match_id TEXT PRIMARY KEY,
			player_count INTEGER NOT NULL,
			connected_player_count INTEGER NOT NULL DEFAULT 0,
			is_started INTEGER NOT NULL DEFAULT 0,
			created_at INTEGER NOT NULL,
			updated_at INTEGER NOT NULL
		)
	`);
	await ensureColumn(
		dbHandle,
		"matches",
		"connected_player_count",
		"INTEGER NOT NULL DEFAULT 0",
	);
	await ensureColumn(
		dbHandle,
		"matches",
		"updated_at",
		"INTEGER NOT NULL DEFAULT 0",
	);
	await dbHandle.execute(`
		CREATE TABLE IF NOT EXISTS pending_players (
			match_id TEXT NOT NULL,
			player_id TEXT NOT NULL,
			expires_at INTEGER NOT NULL,
			created_at INTEGER NOT NULL,
			PRIMARY KEY (match_id, player_id)
		)
	`);
	await dbHandle.execute(
		"CREATE INDEX IF NOT EXISTS matches_open_idx ON matches (is_started, player_count, created_at)",
	);
	await dbHandle.execute(
		"CREATE INDEX IF NOT EXISTS pending_players_match_idx ON pending_players (match_id)",
	);
	await dbHandle.execute(
		"CREATE INDEX IF NOT EXISTS pending_players_expire_idx ON pending_players (expires_at)",
	);
}

async function ensureColumn(
	dbHandle: RawAccess,
	table: "matches",
	column: "connected_player_count" | "updated_at",
	definition: "INTEGER NOT NULL DEFAULT 0",
) {
	const columns = await dbHandle.execute<{ name: string }>(
		`PRAGMA table_info(${table})`,
	);
	if (!columns.some((col) => col.name === column)) {
		await dbHandle.execute(
			`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`,
		);
	}
}
```

- Every request is a queue message, and `run` handles one message at a time. Two players asking at the same moment can't both take the last seat. `findMatch`, `pendingPlayerConnected`, `updateMatch`, and `closeMatch` are all queues.
- `processFindMatch` picks the open lobby with the most players, and the oldest one on a tie. If there is none, it inserts a row and creates the `battleRoyaleMatch` Actor with the match id as its input.
- It returns a new `playerId` and saves it in `pending_players` for 15 seconds. `player_count` counts pending players as well as connected ones, so a lobby that is still filling is never handed out past `LOBBY_CAPACITY` (16).
- A reservation that is never claimed expires at the start of the next message, in `expirePendingPlayers`, which frees the seat.

### Create the match

Each match is its own `battleRoyaleMatch` Actor, keyed by the match id. A player joins by connecting with the `playerId` the matchmaker gave them:

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 {
	LOBBY_CAPACITY,
	LOBBY_COUNTDOWN_TICKS,
	MAX_SPEED,
	PLAYER_MAX_HP,
	SHOOT_ANGLE,
	SHOOT_RANGE,
	TICK_MS,
	WORLD_SIZE,
	ZONE_DAMAGE_PER_TICK,
	ZONE_INITIAL_RADIUS,
	ZONE_MIN_RADIUS,
	ZONE_SHRINK_RATE,
	ZONE_SHRINK_START_TICK,
} from "./config.ts";

interface PlayerEntry {
	connId: string | null;
	color: string;
	x: number;
	y: number;
	lastPositionAt: number;
	hp: number;
	alive: boolean;
	placement: number | null;
	disconnectedAt: number | null;
}

interface State {
	matchId: string;
	tick: number;
	phase: "lobby" | "live" | "finished";
	players: Record<string, PlayerEntry>;
	zone: { centerX: number; centerY: number; radius: number };
	eliminationOrder: string[];
	winnerId: string | null;
	lobbyCountdown: number | null;
}

interface ConnParams {
	playerId?: string;
}

export const battleRoyaleMatch = actor({
	options: { name: "Battle Royale - Match", icon: "skull-crossbones" },
	events: {
		snapshot: event<Snapshot>(),
		shoot: event<ShootEvent>(),
	},
	createState: (_c, input: { matchId: string }): State => ({
		matchId: input.matchId,
		tick: 0,
		phase: "lobby",
		players: {},
		zone: {
			centerX: WORLD_SIZE / 2,
			centerY: WORLD_SIZE / 2,
			radius: ZONE_INITIAL_RADIUS,
		},
		eliminationOrder: [],
		winnerId: null,
		lobbyCountdown: null,
	}),
	onConnect: async (c, conn) => {
		const params = (conn.params as ConnParams | null) ?? {};
		const playerId = params.playerId;
		if (!playerId) {
			conn.disconnect("invalid_player");
			return;
		}
		const existingPlayer = c.state.players[playerId];
		if (existingPlayer) {
			existingPlayer.connId = conn.id;
			existingPlayer.disconnectedAt = null;
		} else {
			const confirmed = await claimPendingPlayer(c, playerId);
			if (!confirmed) {
				conn.disconnect("player_not_pending");
				return;
			}
			const pos = randomPositionInZone(c.state.zone);
			c.state.players[playerId] = {
				connId: conn.id,
				color: getPlayerColor(playerId),
				x: pos.x,
				y: pos.y,
				lastPositionAt: Date.now(),
				hp: PLAYER_MAX_HP,
				alive: true,
				placement: null,
				disconnectedAt: null,
			};
		}

		await updateMatchmaker(c);
		broadcastSnapshot(c);
	},
	onDisconnect: async (c, conn) => {
		const found = findPlayerByConnId(c.state, conn.id);
		if (!found) return;
		const [, player] = found;
		player.connId = null;
		player.disconnectedAt = Date.now();

		await updateMatchmaker(c);
		broadcastSnapshot(c);
	},
	onDestroy: async (c) => {
		const client = c.client<typeof registry>();
		await client.battleRoyaleMatchmaker
			.getOrCreate(["main"])
			.send("closeMatch", { matchId: c.state.matchId });
	},
	run: async (c) => {
		const tick = interval(TICK_MS);
		while (!c.aborted) {
			await tick();
			if (c.aborted) break;

			c.state.tick += 1;
			const now = Date.now();

			if (c.state.phase === "lobby") {
				let removedPlayer = false;
				for (const [id, player] of Object.entries(c.state.players)) {
					if (
						player.disconnectedAt &&
						now - player.disconnectedAt > 5000
					) {
						delete c.state.players[id];
						removedPlayer = true;
					}
				}
				if (removedPlayer) {
					await updateMatchmaker(c);
				}
			}

			if (c.state.phase === "lobby") {
				const connectedCount = Object.values(c.state.players).filter(
					(p) => p.connId !== null,
				).length;
				if (
					connectedCount >= LOBBY_CAPACITY &&
					c.state.lobbyCountdown === null
				) {
					c.state.lobbyCountdown = LOBBY_COUNTDOWN_TICKS;
				}
				if (c.state.lobbyCountdown !== null) {
					c.state.lobbyCountdown -= 1;
					if (c.state.lobbyCountdown <= 0) {
						startGame(c);
					}
				}
			}

			if (c.state.phase === "live") {
				if (c.state.tick > ZONE_SHRINK_START_TICK) {
					c.state.zone.radius = Math.max(
						ZONE_MIN_RADIUS,
						ZONE_INITIAL_RADIUS -
							ZONE_SHRINK_RATE *
								(c.state.tick - ZONE_SHRINK_START_TICK),
					);
				}

				for (const [id, player] of Object.entries(c.state.players)) {
					if (!player.alive) continue;
					const dx = player.x - c.state.zone.centerX;
					const dy = player.y - c.state.zone.centerY;
					const dist = Math.sqrt(dx * dx + dy * dy);
					if (dist > c.state.zone.radius) {
						player.hp -= ZONE_DAMAGE_PER_TICK;
						if (player.hp <= 0) {
							player.hp = 0;
							player.alive = false;
							c.state.eliminationOrder.push(id);
							player.placement = countAlivePlayers(c.state) + 1;
						}
					}
				}

				const alivePlayers = Object.entries(c.state.players).filter(
					([, p]) => p.alive,
				);
				if (alivePlayers.length <= 1) {
					c.state.phase = "finished";
					if (alivePlayers.length === 1) {
						const [winnerId, winner] = alivePlayers[0]!;
						c.state.winnerId = winnerId;
						winner.placement = 1;
					}
				}
			}

			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) return;

			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 }) => {
			if (c.state.phase !== "live") {
				throw new UserError("combat not active", { code: "not_live" });
			}
			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) return;

			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;

				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]!;
				victim.hp -= 3;
				if (victim.hp <= 0) {
					victim.hp = 0;
					victim.alive = false;
					c.state.eliminationOrder.push(closestId);
					victim.placement = countAlivePlayers(c.state) + 1;
				}
			}

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

			const alivePlayers = Object.entries(c.state.players).filter(
				([, p]) => p.alive,
			);
			if (alivePlayers.length <= 1) {
				c.state.phase = "finished";
				if (alivePlayers.length === 1) {
					const [winnerId, winner] = alivePlayers[0]!;
					c.state.winnerId = winnerId;
					winner.placement = 1;
				}
				broadcastSnapshot(c);
			}
		},
		getSnapshot: (c) => buildSnapshot(c),
	},
});

function startGame(c: ActorContextOf<typeof battleRoyaleMatch>) {
	c.state.phase = "live";
	c.state.tick = 0;
	c.state.lobbyCountdown = null;

	c.state.zone = {
		centerX: WORLD_SIZE / 2,
		centerY: WORLD_SIZE / 2,
		radius: ZONE_INITIAL_RADIUS,
	};

	for (const player of Object.values(c.state.players)) {
		const pos = randomPositionInZone(c.state.zone);
		player.x = pos.x;
		player.y = pos.y;
		player.hp = PLAYER_MAX_HP;
		player.alive = true;
		player.lastPositionAt = Date.now();
	}
}

function randomPositionInZone(zone: {
	centerX: number;
	centerY: number;
	radius: number;
}): { x: number; y: number } {
	const angle = Math.random() * Math.PI * 2;
	const r = Math.sqrt(Math.random()) * zone.radius * 0.9;
	return {
		x: Math.max(
			0,
			Math.min(WORLD_SIZE, zone.centerX + Math.cos(angle) * r),
		),
		y: Math.max(
			0,
			Math.min(WORLD_SIZE, zone.centerY + Math.sin(angle) * r),
		),
	};
}

async function updateMatchmaker(c: ActorContextOf<typeof battleRoyaleMatch>) {
	const client = c.client<typeof registry>();
	await client.battleRoyaleMatchmaker
		.getOrCreate(["main"])
		.send("updateMatch", {
			matchId: c.state.matchId,
			connectedPlayerCount: Object.keys(c.state.players).length,
			isStarted: c.state.phase !== "lobby",
		});
}

async function claimPendingPlayer(
	c: ActorContextOf<typeof battleRoyaleMatch>,
	playerId: string,
): Promise<boolean> {
	const client = c.client<typeof registry>();
	const result = await client.battleRoyaleMatchmaker
		.getOrCreate(["main"])
		.send(
			"pendingPlayerConnected",
			{
				matchId: c.state.matchId,
				playerId,
			},
			{ wait: true, timeout: 3_000 },
		);
	if (result.status !== "completed") {
		return false;
	}
	return result.response?.accepted === true;
}

function countAlivePlayers(state: State): number {
	return Object.values(state.players).filter((p) => p.alive).length;
}

interface Snapshot {
	matchId: string;
	tick: number;
	phase: "lobby" | "live" | "finished";
	capacity: number;
	playerCount: number;
	aliveCount: number;
	winnerId: string | null;
	lobbyCountdown: number | null;
	worldSize: number;
	zone: { centerX: number; centerY: number; radius: number };
	players: Record<
		string,
		{
			x: number;
			y: number;
			color: string;
			hp: number;
			maxHp: number;
			alive: boolean;
			placement: number | null;
		}
	>;
}

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

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

function buildSnapshot(c: ActorContextOf<typeof battleRoyaleMatch>): Snapshot {
	const players: Snapshot["players"] = {};
	for (const [id, entry] of Object.entries(c.state.players)) {
		players[id] = {
			x: entry.x,
			y: entry.y,
			color: entry.color,
			hp: entry.hp,
			maxHp: PLAYER_MAX_HP,
			alive: entry.alive,
			placement: entry.placement,
		};
	}
	return {
		matchId: c.state.matchId,
		tick: c.state.tick,
		phase: c.state.phase,
		capacity: LOBBY_CAPACITY,
		playerCount: Object.values(c.state.players).filter(
			(p) => p.connId !== null,
		).length,
		aliveCount: countAlivePlayers(c.state),
		winnerId: c.state.winnerId,
		lobbyCountdown: c.state.lobbyCountdown,
		worldSize: WORLD_SIZE,
		zone: { ...c.state.zone },
		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;
}
```

- `onConnect` reads `playerId` from the connection params. A new player has to be claimed from the matchmaker with `pendingPlayerConnected` first. An unknown or expired id is disconnected with `player_not_pending`.
- `claimPendingPlayer` waits for the matchmaker's answer for at most 3 seconds and treats a timeout as a rejection, so a busy matchmaker can't hang a join.
- A player who reconnects keeps their seat, with the new connection id. During the lobby, a player who stays away for 5 seconds is removed.
- After every join and leave, `updateMatchmaker` sends `updateMatch` with the player count and whether the match has started. The matchmaker stops sending players to a match once `is_started` is set.

> **WARNING:** The `playerId` is the only credential in this example. Anyone who knows it can connect as that player. In production, have the matchmaker issue a signed token and check it in the match, or use [authentication](/guides/authentication).

### Shrink the zone and resolve hits

The match runs a fixed loop in `run`. Movement and shots arrive as actions 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 {
	LOBBY_CAPACITY,
	LOBBY_COUNTDOWN_TICKS,
	MAX_SPEED,
	PLAYER_MAX_HP,
	SHOOT_ANGLE,
	SHOOT_RANGE,
	TICK_MS,
	WORLD_SIZE,
	ZONE_DAMAGE_PER_TICK,
	ZONE_INITIAL_RADIUS,
	ZONE_MIN_RADIUS,
	ZONE_SHRINK_RATE,
	ZONE_SHRINK_START_TICK,
} from "./config.ts";

interface PlayerEntry {
	connId: string | null;
	color: string;
	x: number;
	y: number;
	lastPositionAt: number;
	hp: number;
	alive: boolean;
	placement: number | null;
	disconnectedAt: number | null;
}

interface State {
	matchId: string;
	tick: number;
	phase: "lobby" | "live" | "finished";
	players: Record<string, PlayerEntry>;
	zone: { centerX: number; centerY: number; radius: number };
	eliminationOrder: string[];
	winnerId: string | null;
	lobbyCountdown: number | null;
}

interface ConnParams {
	playerId?: string;
}

export const battleRoyaleMatch = actor({
	options: { name: "Battle Royale - Match", icon: "skull-crossbones" },
	events: {
		snapshot: event<Snapshot>(),
		shoot: event<ShootEvent>(),
	},
	createState: (_c, input: { matchId: string }): State => ({
		matchId: input.matchId,
		tick: 0,
		phase: "lobby",
		players: {},
		zone: {
			centerX: WORLD_SIZE / 2,
			centerY: WORLD_SIZE / 2,
			radius: ZONE_INITIAL_RADIUS,
		},
		eliminationOrder: [],
		winnerId: null,
		lobbyCountdown: null,
	}),
	onConnect: async (c, conn) => {
		const params = (conn.params as ConnParams | null) ?? {};
		const playerId = params.playerId;
		if (!playerId) {
			conn.disconnect("invalid_player");
			return;
		}
		const existingPlayer = c.state.players[playerId];
		if (existingPlayer) {
			existingPlayer.connId = conn.id;
			existingPlayer.disconnectedAt = null;
		} else {
			const confirmed = await claimPendingPlayer(c, playerId);
			if (!confirmed) {
				conn.disconnect("player_not_pending");
				return;
			}
			const pos = randomPositionInZone(c.state.zone);
			c.state.players[playerId] = {
				connId: conn.id,
				color: getPlayerColor(playerId),
				x: pos.x,
				y: pos.y,
				lastPositionAt: Date.now(),
				hp: PLAYER_MAX_HP,
				alive: true,
				placement: null,
				disconnectedAt: null,
			};
		}

		await updateMatchmaker(c);
		broadcastSnapshot(c);
	},
	onDisconnect: async (c, conn) => {
		const found = findPlayerByConnId(c.state, conn.id);
		if (!found) return;
		const [, player] = found;
		player.connId = null;
		player.disconnectedAt = Date.now();

		await updateMatchmaker(c);
		broadcastSnapshot(c);
	},
	onDestroy: async (c) => {
		const client = c.client<typeof registry>();
		await client.battleRoyaleMatchmaker
			.getOrCreate(["main"])
			.send("closeMatch", { matchId: c.state.matchId });
	},
	run: async (c) => {
		const tick = interval(TICK_MS);
		while (!c.aborted) {
			await tick();
			if (c.aborted) break;

			c.state.tick += 1;
			const now = Date.now();

			if (c.state.phase === "lobby") {
				let removedPlayer = false;
				for (const [id, player] of Object.entries(c.state.players)) {
					if (
						player.disconnectedAt &&
						now - player.disconnectedAt > 5000
					) {
						delete c.state.players[id];
						removedPlayer = true;
					}
				}
				if (removedPlayer) {
					await updateMatchmaker(c);
				}
			}

			if (c.state.phase === "lobby") {
				const connectedCount = Object.values(c.state.players).filter(
					(p) => p.connId !== null,
				).length;
				if (
					connectedCount >= LOBBY_CAPACITY &&
					c.state.lobbyCountdown === null
				) {
					c.state.lobbyCountdown = LOBBY_COUNTDOWN_TICKS;
				}
				if (c.state.lobbyCountdown !== null) {
					c.state.lobbyCountdown -= 1;
					if (c.state.lobbyCountdown <= 0) {
						startGame(c);
					}
				}
			}

			if (c.state.phase === "live") {
				if (c.state.tick > ZONE_SHRINK_START_TICK) {
					c.state.zone.radius = Math.max(
						ZONE_MIN_RADIUS,
						ZONE_INITIAL_RADIUS -
							ZONE_SHRINK_RATE *
								(c.state.tick - ZONE_SHRINK_START_TICK),
					);
				}

				for (const [id, player] of Object.entries(c.state.players)) {
					if (!player.alive) continue;
					const dx = player.x - c.state.zone.centerX;
					const dy = player.y - c.state.zone.centerY;
					const dist = Math.sqrt(dx * dx + dy * dy);
					if (dist > c.state.zone.radius) {
						player.hp -= ZONE_DAMAGE_PER_TICK;
						if (player.hp <= 0) {
							player.hp = 0;
							player.alive = false;
							c.state.eliminationOrder.push(id);
							player.placement = countAlivePlayers(c.state) + 1;
						}
					}
				}

				const alivePlayers = Object.entries(c.state.players).filter(
					([, p]) => p.alive,
				);
				if (alivePlayers.length <= 1) {
					c.state.phase = "finished";
					if (alivePlayers.length === 1) {
						const [winnerId, winner] = alivePlayers[0]!;
						c.state.winnerId = winnerId;
						winner.placement = 1;
					}
				}
			}

			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) return;

			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 }) => {
			if (c.state.phase !== "live") {
				throw new UserError("combat not active", { code: "not_live" });
			}
			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) return;

			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;

				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]!;
				victim.hp -= 3;
				if (victim.hp <= 0) {
					victim.hp = 0;
					victim.alive = false;
					c.state.eliminationOrder.push(closestId);
					victim.placement = countAlivePlayers(c.state) + 1;
				}
			}

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

			const alivePlayers = Object.entries(c.state.players).filter(
				([, p]) => p.alive,
			);
			if (alivePlayers.length <= 1) {
				c.state.phase = "finished";
				if (alivePlayers.length === 1) {
					const [winnerId, winner] = alivePlayers[0]!;
					c.state.winnerId = winnerId;
					winner.placement = 1;
				}
				broadcastSnapshot(c);
			}
		},
		getSnapshot: (c) => buildSnapshot(c),
	},
});

function startGame(c: ActorContextOf<typeof battleRoyaleMatch>) {
	c.state.phase = "live";
	c.state.tick = 0;
	c.state.lobbyCountdown = null;

	c.state.zone = {
		centerX: WORLD_SIZE / 2,
		centerY: WORLD_SIZE / 2,
		radius: ZONE_INITIAL_RADIUS,
	};

	for (const player of Object.values(c.state.players)) {
		const pos = randomPositionInZone(c.state.zone);
		player.x = pos.x;
		player.y = pos.y;
		player.hp = PLAYER_MAX_HP;
		player.alive = true;
		player.lastPositionAt = Date.now();
	}
}

function randomPositionInZone(zone: {
	centerX: number;
	centerY: number;
	radius: number;
}): { x: number; y: number } {
	const angle = Math.random() * Math.PI * 2;
	const r = Math.sqrt(Math.random()) * zone.radius * 0.9;
	return {
		x: Math.max(
			0,
			Math.min(WORLD_SIZE, zone.centerX + Math.cos(angle) * r),
		),
		y: Math.max(
			0,
			Math.min(WORLD_SIZE, zone.centerY + Math.sin(angle) * r),
		),
	};
}

async function updateMatchmaker(c: ActorContextOf<typeof battleRoyaleMatch>) {
	const client = c.client<typeof registry>();
	await client.battleRoyaleMatchmaker
		.getOrCreate(["main"])
		.send("updateMatch", {
			matchId: c.state.matchId,
			connectedPlayerCount: Object.keys(c.state.players).length,
			isStarted: c.state.phase !== "lobby",
		});
}

async function claimPendingPlayer(
	c: ActorContextOf<typeof battleRoyaleMatch>,
	playerId: string,
): Promise<boolean> {
	const client = c.client<typeof registry>();
	const result = await client.battleRoyaleMatchmaker
		.getOrCreate(["main"])
		.send(
			"pendingPlayerConnected",
			{
				matchId: c.state.matchId,
				playerId,
			},
			{ wait: true, timeout: 3_000 },
		);
	if (result.status !== "completed") {
		return false;
	}
	return result.response?.accepted === true;
}

function countAlivePlayers(state: State): number {
	return Object.values(state.players).filter((p) => p.alive).length;
}

interface Snapshot {
	matchId: string;
	tick: number;
	phase: "lobby" | "live" | "finished";
	capacity: number;
	playerCount: number;
	aliveCount: number;
	winnerId: string | null;
	lobbyCountdown: number | null;
	worldSize: number;
	zone: { centerX: number; centerY: number; radius: number };
	players: Record<
		string,
		{
			x: number;
			y: number;
			color: string;
			hp: number;
			maxHp: number;
			alive: boolean;
			placement: number | null;
		}
	>;
}

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

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

function buildSnapshot(c: ActorContextOf<typeof battleRoyaleMatch>): Snapshot {
	const players: Snapshot["players"] = {};
	for (const [id, entry] of Object.entries(c.state.players)) {
		players[id] = {
			x: entry.x,
			y: entry.y,
			color: entry.color,
			hp: entry.hp,
			maxHp: PLAYER_MAX_HP,
			alive: entry.alive,
			placement: entry.placement,
		};
	}
	return {
		matchId: c.state.matchId,
		tick: c.state.tick,
		phase: c.state.phase,
		capacity: LOBBY_CAPACITY,
		playerCount: Object.values(c.state.players).filter(
			(p) => p.connId !== null,
		).length,
		aliveCount: countAlivePlayers(c.state),
		winnerId: c.state.winnerId,
		lobbyCountdown: c.state.lobbyCountdown,
		worldSize: WORLD_SIZE,
		zone: { ...c.state.zone },
		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 100 ms until `c.aborted`. Ten ticks per second is enough for a zone that moves slowly and for lobby checks. Each tick ends with one `snapshot` broadcast of the whole match.
- In the lobby, the countdown starts once 16 players are connected and lasts 3 seconds. `startGame` then places everyone inside the zone at full health. Once the match is live, the zone shrinks after 50 ticks, and a player outside it loses 1 HP per tick.
- The client owns movement. `updatePosition` takes the client's position, but limits the distance to `MAX_SPEED` times the time since the last update and keeps it inside the world. A client that tries to teleport only moves as far as it could have walked.
- The server owns combat. `shoot` finds the closest living player within `SHOOT_RANGE` and `SHOOT_ANGLE` of the aim, applies the damage, records the placement, and broadcasts `shoot` so every client can draw the shot.

> **NOTE:** Every player receives every other player's position. On a large map, send each connection only the players near it with `conn.send(...)`, using a spatial index such as `rbush` or `d3-quadtree` to find them. For walls, cover, or bullets that travel, run `@dimforge/rapier2d` (top-down) or `@dimforge/rapier3d` inside the match. 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. A battle royale only needs `battleRoyaleMatchmaker` and `battleRoyaleMatch`. Each Actor file imports `type { registry }` so that `c.client<typeof registry>()` is typed when the match calls the matchmaker and the matchmaker creates a match.

### Connect players

The menu asks the matchmaker for a match, then the game connects to it with the player id it got back:

menu.tsx:

```tsx
import { useState } from "react";
import type { GameClient } from "../../client.ts";

export interface BattleRoyaleMatchInfo {
	matchId: string;
	playerId: string;
}

export function BattleRoyaleMenu({
	client,
	onReady,
	onBack,
}: {
	client: GameClient;
	onReady: (info: BattleRoyaleMatchInfo) => void;
	onBack: () => void;
}) {
	const [status, setStatus] = useState<"idle" | "matching" | "error">("idle");
	const [error, setError] = useState("");

	const findMatch = async () => {
		setStatus("matching");
		try {
			const mm = client.battleRoyaleMatchmaker
				.getOrCreate(["main"])
				.connect();
			const result = await mm.send(
				"findMatch",
				{},
				{ wait: true, timeout: 10_000 },
			);
			mm.dispose();
			const response = result.response;
			if (!response?.matchId || !response?.playerId) {
				throw new Error("Matchmaker did not return a valid match");
			}
			onReady(response);
		} catch (err) {
			setError(err instanceof Error ? err.message : String(err));
			setStatus("error");
		}
	};

	return (
		<div className="app">
			<button className="back-link" onClick={onBack}>
				&larr; Back
			</button>
			<div className="menu-container">
				<h2>Battle Royale</h2>
				<p className="menu-description">
					Join a lobby, wait for players, then fight in a shrinking
					zone. Last one standing wins. WASD to move, click to shoot.
				</p>
				<button
					className="btn btn-primary"
					onClick={() => void findMatch()}
					disabled={status === "matching"}
				>
					{status === "matching" ? "Finding match..." : "Find Match"}
				</button>
				{status === "error" && (
					<div className="status-box">
						<p className="status-error">{error}</p>
					</div>
				)}
			</div>
		</div>
	);
}
```

battle-royale-game.ts:

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

const PLAYER_RADIUS = 10;
const LERP_FACTOR = 0.2;
const GRID_SPACING = 50;
const SEND_RATE_MS = 50;
const MOVE_SPEED = 150;
const SHOT_LINE_DURATION = 150;
const RUBBER_BAND_THRESHOLD = 40;
const VIEWPORT_SIZE = 600;

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

export class BattleRoyaleGame {
	private stopped = false;
	private rafId = 0;
	private worldSize = 1200;
	private targets: Record<
		string,
		{
			x: number;
			y: number;
			color: string;
			hp: number;
			maxHp: number;
			alive: boolean;
			placement: number | null;
		}
	> = {};
	private display: Record<string, { x: number; y: number }> = {};
	private keys: Record<string, boolean> = {};
	private phase: "lobby" | "live" | "finished" = "lobby";
	private winnerId: string | null = null;
	private playerCount = 0;
	private aliveCount = 0;
	private capacity = 16;
	private lobbyCountdown: number | null = null;
	private zone = { centerX: 600, centerY: 600, radius: 550 };
	private shotLines: ShotLine[] = [];
	private lastSendTime = 0;
	private localX = 0;
	private localY = 0;
	private lastFrameTime = 0;
	private botInterval = 0;
	private conn: BattleRoyaleMatchConn;

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

		this.conn.on("snapshot", (snap) => {
			this.worldSize = snap.worldSize;
			this.phase = snap.phase;
			this.winnerId = snap.winnerId;
			this.playerCount = snap.playerCount;
			this.aliveCount = snap.aliveCount;
			this.capacity = snap.capacity;
			this.lobbyCountdown = snap.lobbyCountdown;
			this.zone = snap.zone;

			for (const [id, data] of Object.entries(snap.players)) {
				this.targets[id] = data;
				if (id === this.matchInfo.playerId) {
					// Snap to server position on first snapshot or large rubber-band.
					if (this.localX === 0 && this.localY === 0) {
						this.localX = data.x;
						this.localY = data.y;
					} else {
						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 };
				}
			}
			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) => {
			this.shotLines.push({
				fromX: shot.fromX,
				fromY: shot.fromY,
				toX: shot.fromX + shot.dirX * 250,
				toY: shot.fromY + shot.dirY * 250,
				hit: shot.hitPlayerId !== null,
				createdAt: performance.now(),
			});
		});

		if (options.bot) {
			this.botInterval = window.setInterval(() => {
				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 (this.phase === "live" && Math.random() < 0.15) {
					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 myData = this.targets[this.matchInfo.playerId];
		if (!myData?.alive) return;
		const rect = this.canvas.getBoundingClientRect();
		// Click relative to viewport center (player position).
		const clickX = e.clientX - rect.left - VIEWPORT_SIZE / 2;
		const clickY = e.clientY - rect.top - VIEWPORT_SIZE / 2;
		const mag = Math.sqrt(clickX * clickX + clickY * clickY);
		if (mag === 0) return;
		this.conn
			.shoot({ dirX: clickX / mag, dirY: clickY / 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 myData = this.targets[this.matchInfo.playerId];
		const myAlive = myData?.alive ?? true;

		// Movement.
		if ((this.phase === "live" || this.phase === "lobby") && myAlive) {
			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,
					),
				);
			}
			if (now - this.lastSendTime >= SEND_RATE_MS) {
				this.lastSendTime = now;
				this.conn
					.updatePosition({ x: this.localX, y: this.localY })
					.catch(() => {});
			}
		}

		// Camera.
		const camX = this.localX - VIEWPORT_SIZE / 2;
		const camY = this.localY - VIEWPORT_SIZE / 2;

		ctx.fillStyle = "#0a0a0a";
		ctx.fillRect(0, 0, canvas.width, canvas.height);

		// Grid.
		ctx.strokeStyle = "#1a1a1a";
		ctx.lineWidth = 1;
		ctx.beginPath();
		const startX = Math.floor(camX / GRID_SPACING) * GRID_SPACING;
		const startY = Math.floor(camY / GRID_SPACING) * GRID_SPACING;
		for (let x = startX; x <= camX + VIEWPORT_SIZE; x += GRID_SPACING) {
			ctx.moveTo(x - camX, 0);
			ctx.lineTo(x - camX, VIEWPORT_SIZE);
		}
		for (let y = startY; y <= camY + VIEWPORT_SIZE; y += GRID_SPACING) {
			ctx.moveTo(0, y - camY);
			ctx.lineTo(VIEWPORT_SIZE, y - camY);
		}
		ctx.stroke();

		// World boundary.
		ctx.strokeStyle = "#ff3b30";
		ctx.lineWidth = 2;
		ctx.strokeRect(-camX, -camY, this.worldSize, this.worldSize);

		// Zone circle.
		if (this.phase === "live") {
			ctx.beginPath();
			ctx.arc(
				this.zone.centerX - camX,
				this.zone.centerY - camY,
				this.zone.radius,
				0,
				Math.PI * 2,
			);
			ctx.strokeStyle = "rgba(59, 130, 246, 0.6)";
			ctx.lineWidth = 3;
			ctx.stroke();

			// Zone fill outside.
			ctx.save();
			ctx.beginPath();
			ctx.rect(
				-camX - 100,
				-camY - 100,
				this.worldSize + 200,
				this.worldSize + 200,
			);
			ctx.arc(
				this.zone.centerX - camX,
				this.zone.centerY - camY,
				this.zone.radius,
				0,
				Math.PI * 2,
				true,
			);
			ctx.fillStyle = "rgba(59, 130, 246, 0.08)";
			ctx.fill();
			ctx.restore();
		}

		// 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 - camX, shot.fromY - camY);
			ctx.lineTo(shot.toX - camX, shot.toY - camY);
			ctx.stroke();
		}

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

			if (isMe) {
				px = this.localX - camX;
				py = this.localY - camY;
			} 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 - camX;
				py = d.y - camY;
			}

			if (
				px < -50 ||
				px > VIEWPORT_SIZE + 50 ||
				py < -50 ||
				py > VIEWPORT_SIZE + 50
			)
				continue;

			const color = target.color;

			// Player circle.
			ctx.beginPath();
			ctx.arc(px, py, PLAYER_RADIUS, 0, Math.PI * 2);
			ctx.fillStyle = target.alive ? color : "#555";
			ctx.fill();
			if (isMe) {
				ctx.lineWidth = 2;
				ctx.strokeStyle = "#ffffff";
				ctx.stroke();
			}

			// HP bar.
			if (target.alive && this.phase === "live") {
				const barWidth = 24;
				const barHeight = 3;
				const barX = px - barWidth / 2;
				const barY = py - PLAYER_RADIUS - 8;
				const hpRatio = target.hp / target.maxHp;
				ctx.fillStyle = "#333";
				ctx.fillRect(barX, barY, barWidth, barHeight);
				ctx.fillStyle =
					hpRatio > 0.5
						? "#30d158"
						: hpRatio > 0.25
							? "#ff4f00"
							: "#ff3b30";
				ctx.fillRect(barX, barY, barWidth * hpRatio, barHeight);
			}

			// Label.
			ctx.fillStyle = "#ffffff";
			ctx.font = "10px sans-serif";
			ctx.textAlign = "center";
			ctx.fillText(id.slice(0, 6), px, py - PLAYER_RADIUS - 12);
		}

		// HUD.
		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";
		if (this.phase === "lobby") {
			const countdownText =
				this.lobbyCountdown !== null
					? `  Starting in ${Math.ceil(this.lobbyCountdown / 10)}...`
					: "";
			ctx.fillText(
				`LOBBY  ${this.playerCount}/${this.capacity} players${countdownText}`,
				8,
				18,
			);
		} else if (this.phase === "live") {
			ctx.fillText(`LIVE  ${this.aliveCount} alive`, 8, 18);
		} else {
			ctx.fillText("FINISHED", 8, 18);
		}

		// Phase overlays.
		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";
			if (this.winnerId === this.matchInfo.playerId) {
				ctx.fillStyle = "#ffd700";
				ctx.fillText(
					"Victory Royale!",
					canvas.width / 2,
					canvas.height / 2,
				);
			} else {
				const placement = myData?.placement;
				ctx.fillText(
					placement ? `Eliminated #${placement}` : "Game Over",
					canvas.width / 2,
					canvas.height / 2,
				);
			}
		}

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

- `mm.send("findMatch", {}, { wait: true })` waits for the queue's reply. The menu disposes the matchmaker connection right after, because the player now talks only to the match.
- The game uses `battleRoyaleMatch.get(...)`, not `getOrCreate`, because only the matchmaker creates matches. `playerId` goes in `params`, which is where `onConnect` reads it.
- 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. Other players are smoothed toward their latest position.
- Game state lives in a class that draws to a canvas with `requestAnimationFrame`. React only renders the menu and buttons.

### 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 **Battle Royale**, and click **Find Match**. The header shows `LOBBY 1/16 players`. Click **Add Bot** 15 times to fill the lobby. After a 3-second countdown the header switches to `LIVE 16 alive` and the zone starts to close. Move with WASD and click to shoot. The last player alive wins.

## Next steps

- [IO Game](/guides/io-game): open lobbies that players join and leave at any time.
- [Queues](/actors/docs/queues): how the matchmaker handles one request at a time.
- [Connections](/actors/docs/connections): connection params, `onConnect`, and per-connection state.
