Skip to main content
Games

Battle Royale

Build a battle royale game like Fortnite, Apex Legends, or PUBG, where players fill a lobby and fight in a shrinking zone until one is left.

See the full example on GitHub. View on GitHub
Game clientbrowser canvasbattleRoyaleMatchmakerone, keyed mainMatchmaker SQLitematches, pending_playersbattleRoyaleMatchlobby, zone, combatfindMatch1create2matchId, playerId3connect with playerId4pendingPlayerConnected5c.db.executeupdatePosition, shoot6snapshot, shoot events7One per matchRivet ActorCallReply

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

npm add rivetkit

Create the matchmaker

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

/*
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 } 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;

interface MatchmakerVars {
	// The lobby being created right now, so requests that find no open lobby
	// at the same time wait for it instead of opening one each
	openingLobby: Promise<void> | null;
}

export const battleRoyaleMatchmaker = actor({
	options: { name: "Battle Royale - Matchmaker", icon: "skull-crossbones" },
	db: db({
		onMigrate: migrateTables,
	}),
	createVars: (): MatchmakerVars => ({ openingLobby: null }),
	actions: {
		findMatch: async (
			c,
		): Promise<{ matchId: string; playerId: string }> => {
			const playerId = crypto.randomUUID();
			let matchId = await reserveSeat(c, playerId);
			while (matchId === null) {
				await openLobby(c);
				matchId = await reserveSeat(c, playerId);
			}
			return { matchId, playerId };
		},
		pendingPlayerConnected: async (
			c,
			input: { matchId: string; playerId: string },
		): Promise<{ accepted: boolean }> => {
			const now = Date.now();
			return await c.db.transaction(
				async (tx) => {
					// Deleting the reservation is what claims it, so a player
					// who connects twice at once is accepted only once.
					const rows = await tx.execute<{ expires_at: number }>(
						`DELETE FROM pending_players WHERE match_id = ? AND player_id = ? RETURNING expires_at`,
						input.matchId,
						input.playerId,
					);
					const reservation = rows[0];
					if (!reservation) {
						return { accepted: false };
					}

					const accepted = reservation.expires_at > now;
					if (accepted) {
						// Count the seat as occupied right away. Otherwise it would
						// look free until the match reports its new count.
						await tx.execute(
							`UPDATE matches SET connected_player_count = connected_player_count + 1 WHERE match_id = ?`,
							input.matchId,
						);
					}
					await syncClaimedPlayerCount(tx, input.matchId, now);
					return { accepted };
				},
				{ name: "claim-seat" },
			);
		},
		updateMatch: async (
			c,
			input: {
				matchId: string;
				connectedPlayerCount: number;
				isStarted: boolean;
			},
		) => {
			const now = Date.now();
			await c.db.transaction(
				async (tx) => {
					await tx.execute(
						`UPDATE matches SET connected_player_count = ?, is_started = ?, updated_at = ? WHERE match_id = ?`,
						input.connectedPlayerCount,
						input.isStarted ? 1 : 0,
						now,
						input.matchId,
					);
					await syncClaimedPlayerCount(tx, input.matchId, now);
				},
				{ name: "update-match" },
			);
		},
		closeMatch: async (c, input: { matchId: string }) => {
			// Delete both in one transaction, so findMatch can't reserve a seat
			// in a match that is halfway closed.
			await c.db.transaction(
				async (tx) => {
					await tx.execute(
						`DELETE FROM pending_players WHERE match_id = ?`,
						input.matchId,
					);
					await tx.execute(
						`DELETE FROM matches WHERE match_id = ?`,
						input.matchId,
					);
				},
				{ name: "close-match" },
			);
		},
	},
});

/**
 * Reserves a seat for `playerId` in the fullest lobby that has not started
 * and returns its match id, or null when no lobby has room.
 */
async function reserveSeat(
	c: ActorContextOf<typeof battleRoyaleMatchmaker>,
	playerId: string,
): Promise<string | null> {
	const now = Date.now();

	// Actions run concurrently, so pick the lobby and take the seat in one
	// transaction. Two players asking at once can't both take the last seat.
	return await c.db.transaction(
		async (tx) => {
			await expirePendingPlayers(tx, now);

			const rows = await tx.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,
			);
			const matchId = rows[0]?.match_id;
			if (!matchId) {
				return null;
			}

			await tx.execute(
				`INSERT INTO pending_players (match_id, player_id, expires_at, created_at) VALUES (?, ?, ?, ?)`,
				matchId,
				playerId,
				now + JOIN_RESERVATION_TTL_MS,
				now,
			);
			await syncClaimedPlayerCount(tx, matchId, now);
			return matchId;
		},
		{ name: "reserve-seat" },
	);
}

/**
 * Opens one new empty lobby. Requests that find no open lobby at the same
 * time share the lobby that is already being opened.
 */
function openLobby(
	c: ActorContextOf<typeof battleRoyaleMatchmaker>,
): Promise<void> {
	if (!c.vars.openingLobby) {
		c.vars.openingLobby = createLobby(c).finally(() => {
			c.vars.openingLobby = null;
		});
	}
	return c.vars.openingLobby;
}

async function createLobby(
	c: ActorContextOf<typeof battleRoyaleMatchmaker>,
): Promise<void> {
	const matchId = crypto.randomUUID();

	// Create the match before saving its row, so findMatch never hands out a
	// match that doesn't exist yet. This can't run inside a transaction,
	// because a transaction must not wait on another actor.
	const client = c.client<typeof registry>();
	await client.battleRoyaleMatch.create([matchId]);

	const now = Date.now();
	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,
	);
}

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

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

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

async function syncClaimedPlayerCount(
	tx: RawAccess,
	matchId: string,
	now: number,
): Promise<void> {
	await tx.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}`,
		);
	}
}
  • findMatch, pendingPlayerConnected, updateMatch, and closeMatch are actions. Actions run at the same time, so each one reads and changes the tables inside one c.db.transaction.
  • reserveSeat picks the open lobby with the most players, and the oldest one on a tie, and reserves a seat in the same transaction. Two players asking at the same moment can’t both take the last seat.
  • If there is none, openLobby creates a battleRoyaleMatch Actor keyed by a new match id, inserts its row, and findMatch tries again. The Actor is created outside the transaction, because a transaction must not wait on another Actor. Requests that find no open lobby at the same time wait for the same new lobby in c.vars.openingLobby instead of opening one each.
  • findMatch 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).
  • pendingPlayerConnected claims a reservation by deleting it with DELETE ... RETURNING, so each playerId is accepted only once. The same transaction counts the seat as connected, so it never looks free before the match reports its new count.
  • A reservation that is never claimed expires at the start of the next findMatch, 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:

import { type ActorContextOf, actor, event, UserError } from "rivetkit";
import { db } from "rivetkit/db";
import { interval } from "rivetkit/utils";
import type { registry } from "../index.ts";
import { getPlayerColor } from "../player-color.ts";
import {
	LOBBY_CAPACITY,
	LOBBY_COUNTDOWN_TICKS,
	MAX_SPEED,
	PLAYER_MAX_HP,
	SHOOT_ANGLE,
	SHOOT_RANGE,
	SNAPSHOT_INTERVAL_MS,
	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 MatchVars {
	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;
	// `onWake` stores its loop here so `onSleep` can wait for it.
	loop: Promise<void> | null;
}

interface ConnParams {
	playerId?: string;
}

type MatchInfoRow = {
	tick: number;
	phase: MatchVars["phase"];
	lobby_countdown: number | null;
	zone_center_x: number;
	zone_center_y: number;
	zone_radius: number;
	winner_id: string | null;
};

type PlayerRow = {
	player_id: string;
	x: number;
	y: number;
	hp: number;
	alive: number;
	placement: number | null;
};

export const battleRoyaleMatch = actor({
	options: { name: "Battle Royale - Match", icon: "skull-crossbones" },
	events: {
		snapshot: event<Snapshot>(),
		shoot: event<ShootEvent>(),
	},
	db: db({
		onMigrate: async (db) => {
			await db.execute(`
				CREATE TABLE IF NOT EXISTS match_info (
					id INTEGER PRIMARY KEY CHECK (id = 1),
					tick INTEGER NOT NULL,
					phase TEXT NOT NULL,
					lobby_countdown INTEGER,
					zone_center_x REAL NOT NULL,
					zone_center_y REAL NOT NULL,
					zone_radius REAL NOT NULL,
					winner_id TEXT
				)
			`);
			await db.execute(`
				CREATE TABLE IF NOT EXISTS players (
					player_id TEXT PRIMARY KEY,
					x REAL NOT NULL,
					y REAL NOT NULL,
					hp INTEGER NOT NULL,
					alive INTEGER NOT NULL,
					placement INTEGER,
					elimination_index INTEGER
				)
			`);
		},
	}),
	// The match runs in memory and starts from the last save. Its key is the
	// match id.
	createVars: async (c): Promise<MatchVars> => {
		const matchId = c.key[0]!;
		const [info] = await c.db.execute<MatchInfoRow>(
			"SELECT tick, phase, lobby_countdown, zone_center_x, zone_center_y, zone_radius, winner_id FROM match_info WHERE id = 1",
		);
		if (!info) {
			return {
				matchId,
				tick: 0,
				phase: "lobby",
				players: {},
				zone: {
					centerX: WORLD_SIZE / 2,
					centerY: WORLD_SIZE / 2,
					radius: ZONE_INITIAL_RADIUS,
				},
				eliminationOrder: [],
				winnerId: null,
				lobbyCountdown: null,
				loop: null,
			};
		}

		const rows = await c.db.execute<PlayerRow>(
			"SELECT player_id, x, y, hp, alive, placement FROM players",
		);
		const eliminated = await c.db.execute<{ player_id: string }>(
			"SELECT player_id FROM players WHERE elimination_index IS NOT NULL ORDER BY elimination_index",
		);

		// No connection survives a restart, so every claimed player starts
		// disconnected. The lobby drops the ones who don't reconnect in time.
		const players: Record<string, PlayerEntry> = {};
		for (const row of rows) {
			players[row.player_id] = {
				connId: null,
				color: getPlayerColor(row.player_id),
				x: row.x,
				y: row.y,
				lastPositionAt: Date.now(),
				hp: row.hp,
				alive: row.alive === 1,
				placement: row.placement,
				disconnectedAt: Date.now(),
			};
		}
		return {
			matchId,
			tick: info.tick,
			phase: info.phase,
			players,
			zone: {
				centerX: info.zone_center_x,
				centerY: info.zone_center_y,
				radius: info.zone_radius,
			},
			eliminationOrder: eliminated.map((row) => row.player_id),
			winnerId: info.winner_id,
			lobbyCountdown: info.lobby_countdown,
			loop: 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.vars.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.vars.zone);
			c.vars.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,
			};

			// The matchmaker has used up this player's reservation, so save
			// the player now. A crash before the next save would otherwise
			// turn them away with player_not_pending when they reconnect.
			await saveMatch(c);
		}

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

		await updateMatchmaker(c);
		broadcastSnapshot(c);
	},
	onDestroy: async (c) => {
		// `c.abortSignal` also fires on destroy. Let the loop finish its last
		// save before the database closes.
		await c.vars.loop;
		const client = c.client<typeof registry>();
		await client.battleRoyaleMatchmaker
			.getOrCreate(["main"])
			.closeMatch({ matchId: c.vars.matchId });
	},
	// `onWake` runs on every start, so the loop restarts after a sleep or crash
	onWake: (c) => {
		c.vars.loop = runMatch(c).catch((error) => {
			c.log.error({ msg: "match loop failed", error });
		});
	},
	// `c.abortSignal` fires on shutdown, so the loop saves and exits on its own
	onSleep: async (c) => {
		await c.vars.loop;
	},
	actions: {
		updatePosition: (c, input: { x: number; y: number }) => {
			const found = findPlayerByConnId(c.vars, c.conn.id);
			if (!found) {
				throw new UserError("player not found", {
					code: "player_not_found",
				});
			}
			const [, player] = found;
			if (!player.alive) 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.vars.phase !== "live") {
				throw new UserError("combat not active", { code: "not_live" });
			}
			const found = findPlayerByConnId(c.vars, c.conn.id);
			if (!found) {
				throw new UserError("player not found", {
					code: "player_not_found",
				});
			}
			const [shooterId, shooter] = found;
			if (!shooter.alive) 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.vars.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.vars.players[closestId]!;
				victim.hp -= 3;
				if (victim.hp <= 0) {
					victim.hp = 0;
					victim.alive = false;
					c.vars.eliminationOrder.push(closestId);
					victim.placement = countAlivePlayers(c.vars) + 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.vars.players).filter(
				([, p]) => p.alive,
			);
			if (alivePlayers.length <= 1) {
				c.vars.phase = "finished";
				if (alivePlayers.length === 1) {
					const [winnerId, winner] = alivePlayers[0]!;
					c.vars.winnerId = winnerId;
					winner.placement = 1;
				}
				broadcastSnapshot(c);
			}
		},
		getSnapshot: (c) => buildSnapshot(c),
	},
});

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

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

		// The 5 second grace is much shorter than the idle timeout, so an
		// empty lobby removes its last players here before it goes to sleep.
		if (c.vars.phase === "lobby") {
			let removedPlayer = false;
			for (const [id, player] of Object.entries(c.vars.players)) {
				if (
					player.disconnectedAt &&
					now - player.disconnectedAt > 5000
				) {
					delete c.vars.players[id];
					removedPlayer = true;
				}
			}
			if (removedPlayer) {
				await updateMatchmaker(c);
			}
		}

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

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

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

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

		broadcastSnapshot(c);

		// Ticks and actions only change memory. Save to SQLite every
		// SNAPSHOT_INTERVAL_MS instead of on every tick.
		if (now - savedAt >= SNAPSHOT_INTERVAL_MS) {
			await saveMatch(c);
			savedAt = now;
		}
	}

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

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

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

	for (const player of Object.values(c.vars.players)) {
		const pos = randomPositionInZone(c.vars.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),
		),
	};
}

/**
 * Tells the matchmaker how full this match is. The count only steers new
 * players to a lobby, and the next join or leave sends it again, so a failed
 * update is logged instead of stopping the caller or the tick loop.
 */
async function updateMatchmaker(c: ActorContextOf<typeof battleRoyaleMatch>) {
	const client = c.client<typeof registry>();
	try {
		await client.battleRoyaleMatchmaker.getOrCreate(["main"]).updateMatch({
			matchId: c.vars.matchId,
			connectedPlayerCount: Object.keys(c.vars.players).length,
			isStarted: c.vars.phase !== "lobby",
		});
	} catch (error) {
		c.log.warn({ msg: "updating the matchmaker failed", error });
	}
}

async function claimPendingPlayer(
	c: ActorContextOf<typeof battleRoyaleMatch>,
	playerId: string,
): Promise<boolean> {
	const client = c.client<typeof registry>();
	const result = await client.battleRoyaleMatchmaker
		.getOrCreate(["main"])
		.pendingPlayerConnected({ matchId: c.vars.matchId, playerId });
	return result.accepted;
}

/**
 * Writes the match and every claimed player to SQLite in one transaction, so
 * a crash leaves either the previous save or this one, never part of both.
 */
async function saveMatch(c: ActorContextOf<typeof battleRoyaleMatch>) {
	// Copy the values first, because actions and ticks can run while the
	// transaction waits on SQLite.
	const { tick, phase, lobbyCountdown, winnerId } = c.vars;
	const zone = { ...c.vars.zone };
	const players = Object.entries(c.vars.players).map(([playerId, player]) => {
		const eliminationIndex = c.vars.eliminationOrder.indexOf(playerId);
		return {
			playerId,
			x: player.x,
			y: player.y,
			hp: player.hp,
			alive: player.alive ? 1 : 0,
			placement: player.placement,
			eliminationIndex: eliminationIndex === -1 ? null : eliminationIndex,
		};
	});

	await c.db.transaction(
		async (tx) => {
			await tx.execute(
				"INSERT OR REPLACE INTO match_info (id, tick, phase, lobby_countdown, zone_center_x, zone_center_y, zone_radius, winner_id) VALUES (1, ?, ?, ?, ?, ?, ?, ?)",
				tick,
				phase,
				lobbyCountdown,
				zone.centerX,
				zone.centerY,
				zone.radius,
				winnerId,
			);
			await tx.execute("DELETE FROM players");
			for (const player of players) {
				await tx.execute(
					"INSERT INTO players (player_id, x, y, hp, alive, placement, elimination_index) VALUES (?, ?, ?, ?, ?, ?, ?)",
					player.playerId,
					player.x,
					player.y,
					player.hp,
					player.alive,
					player.placement,
					player.eliminationIndex,
				);
			}
		},
		{ name: "save-match" },
	);
}

function countAlivePlayers(vars: MatchVars): number {
	return Object.values(vars.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.vars.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.vars.matchId,
		tick: c.vars.tick,
		phase: c.vars.phase,
		capacity: LOBBY_CAPACITY,
		playerCount: Object.values(c.vars.players).filter(
			(p) => p.connId !== null,
		).length,
		aliveCount: countAlivePlayers(c.vars),
		winnerId: c.vars.winnerId,
		lobbyCountdown: c.vars.lobbyCountdown,
		worldSize: WORLD_SIZE,
		zone: { ...c.vars.zone },
		players,
	};
}

function findPlayerByConnId(
	vars: MatchVars,
	connId: string,
): [string, PlayerEntry] | null {
	for (const [id, entry] of Object.entries(vars.players)) {
		if (entry.connId === connId) return [id, entry];
	}
	return null;
}
  • 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 calls the matchmaker’s pendingPlayerConnected action and returns its answer. If the matchmaker can’t be reached, the call throws and the connection fails instead of turning a valid player away.
  • 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.
  • A claim uses up the reservation, so onConnect saves the new player to SQLite right away. After a crash, createVars restores every claimed player without a connection, so a player who reconnects gets their seat back instead of player_not_pending.
  • After every join and leave, updateMatchmaker calls updateMatch with the player count and whether the match has started. The matchmaker stops sending players to a match once is_started is set.

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.

Shrink the zone and resolve hits

The match runs a fixed loop that onWake starts. Movement and shots arrive as actions between ticks:

import { type ActorContextOf, actor, event, UserError } from "rivetkit";
import { db } from "rivetkit/db";
import { interval } from "rivetkit/utils";
import type { registry } from "../index.ts";
import { getPlayerColor } from "../player-color.ts";
import {
	LOBBY_CAPACITY,
	LOBBY_COUNTDOWN_TICKS,
	MAX_SPEED,
	PLAYER_MAX_HP,
	SHOOT_ANGLE,
	SHOOT_RANGE,
	SNAPSHOT_INTERVAL_MS,
	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 MatchVars {
	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;
	// `onWake` stores its loop here so `onSleep` can wait for it.
	loop: Promise<void> | null;
}

interface ConnParams {
	playerId?: string;
}

type MatchInfoRow = {
	tick: number;
	phase: MatchVars["phase"];
	lobby_countdown: number | null;
	zone_center_x: number;
	zone_center_y: number;
	zone_radius: number;
	winner_id: string | null;
};

type PlayerRow = {
	player_id: string;
	x: number;
	y: number;
	hp: number;
	alive: number;
	placement: number | null;
};

export const battleRoyaleMatch = actor({
	options: { name: "Battle Royale - Match", icon: "skull-crossbones" },
	events: {
		snapshot: event<Snapshot>(),
		shoot: event<ShootEvent>(),
	},
	db: db({
		onMigrate: async (db) => {
			await db.execute(`
				CREATE TABLE IF NOT EXISTS match_info (
					id INTEGER PRIMARY KEY CHECK (id = 1),
					tick INTEGER NOT NULL,
					phase TEXT NOT NULL,
					lobby_countdown INTEGER,
					zone_center_x REAL NOT NULL,
					zone_center_y REAL NOT NULL,
					zone_radius REAL NOT NULL,
					winner_id TEXT
				)
			`);
			await db.execute(`
				CREATE TABLE IF NOT EXISTS players (
					player_id TEXT PRIMARY KEY,
					x REAL NOT NULL,
					y REAL NOT NULL,
					hp INTEGER NOT NULL,
					alive INTEGER NOT NULL,
					placement INTEGER,
					elimination_index INTEGER
				)
			`);
		},
	}),
	// The match runs in memory and starts from the last save. Its key is the
	// match id.
	createVars: async (c): Promise<MatchVars> => {
		const matchId = c.key[0]!;
		const [info] = await c.db.execute<MatchInfoRow>(
			"SELECT tick, phase, lobby_countdown, zone_center_x, zone_center_y, zone_radius, winner_id FROM match_info WHERE id = 1",
		);
		if (!info) {
			return {
				matchId,
				tick: 0,
				phase: "lobby",
				players: {},
				zone: {
					centerX: WORLD_SIZE / 2,
					centerY: WORLD_SIZE / 2,
					radius: ZONE_INITIAL_RADIUS,
				},
				eliminationOrder: [],
				winnerId: null,
				lobbyCountdown: null,
				loop: null,
			};
		}

		const rows = await c.db.execute<PlayerRow>(
			"SELECT player_id, x, y, hp, alive, placement FROM players",
		);
		const eliminated = await c.db.execute<{ player_id: string }>(
			"SELECT player_id FROM players WHERE elimination_index IS NOT NULL ORDER BY elimination_index",
		);

		// No connection survives a restart, so every claimed player starts
		// disconnected. The lobby drops the ones who don't reconnect in time.
		const players: Record<string, PlayerEntry> = {};
		for (const row of rows) {
			players[row.player_id] = {
				connId: null,
				color: getPlayerColor(row.player_id),
				x: row.x,
				y: row.y,
				lastPositionAt: Date.now(),
				hp: row.hp,
				alive: row.alive === 1,
				placement: row.placement,
				disconnectedAt: Date.now(),
			};
		}
		return {
			matchId,
			tick: info.tick,
			phase: info.phase,
			players,
			zone: {
				centerX: info.zone_center_x,
				centerY: info.zone_center_y,
				radius: info.zone_radius,
			},
			eliminationOrder: eliminated.map((row) => row.player_id),
			winnerId: info.winner_id,
			lobbyCountdown: info.lobby_countdown,
			loop: 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.vars.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.vars.zone);
			c.vars.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,
			};

			// The matchmaker has used up this player's reservation, so save
			// the player now. A crash before the next save would otherwise
			// turn them away with player_not_pending when they reconnect.
			await saveMatch(c);
		}

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

		await updateMatchmaker(c);
		broadcastSnapshot(c);
	},
	onDestroy: async (c) => {
		// `c.abortSignal` also fires on destroy. Let the loop finish its last
		// save before the database closes.
		await c.vars.loop;
		const client = c.client<typeof registry>();
		await client.battleRoyaleMatchmaker
			.getOrCreate(["main"])
			.closeMatch({ matchId: c.vars.matchId });
	},
	// `onWake` runs on every start, so the loop restarts after a sleep or crash
	onWake: (c) => {
		c.vars.loop = runMatch(c).catch((error) => {
			c.log.error({ msg: "match loop failed", error });
		});
	},
	// `c.abortSignal` fires on shutdown, so the loop saves and exits on its own
	onSleep: async (c) => {
		await c.vars.loop;
	},
	actions: {
		updatePosition: (c, input: { x: number; y: number }) => {
			const found = findPlayerByConnId(c.vars, c.conn.id);
			if (!found) {
				throw new UserError("player not found", {
					code: "player_not_found",
				});
			}
			const [, player] = found;
			if (!player.alive) 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.vars.phase !== "live") {
				throw new UserError("combat not active", { code: "not_live" });
			}
			const found = findPlayerByConnId(c.vars, c.conn.id);
			if (!found) {
				throw new UserError("player not found", {
					code: "player_not_found",
				});
			}
			const [shooterId, shooter] = found;
			if (!shooter.alive) 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.vars.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.vars.players[closestId]!;
				victim.hp -= 3;
				if (victim.hp <= 0) {
					victim.hp = 0;
					victim.alive = false;
					c.vars.eliminationOrder.push(closestId);
					victim.placement = countAlivePlayers(c.vars) + 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.vars.players).filter(
				([, p]) => p.alive,
			);
			if (alivePlayers.length <= 1) {
				c.vars.phase = "finished";
				if (alivePlayers.length === 1) {
					const [winnerId, winner] = alivePlayers[0]!;
					c.vars.winnerId = winnerId;
					winner.placement = 1;
				}
				broadcastSnapshot(c);
			}
		},
		getSnapshot: (c) => buildSnapshot(c),
	},
});

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

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

		// The 5 second grace is much shorter than the idle timeout, so an
		// empty lobby removes its last players here before it goes to sleep.
		if (c.vars.phase === "lobby") {
			let removedPlayer = false;
			for (const [id, player] of Object.entries(c.vars.players)) {
				if (
					player.disconnectedAt &&
					now - player.disconnectedAt > 5000
				) {
					delete c.vars.players[id];
					removedPlayer = true;
				}
			}
			if (removedPlayer) {
				await updateMatchmaker(c);
			}
		}

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

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

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

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

		broadcastSnapshot(c);

		// Ticks and actions only change memory. Save to SQLite every
		// SNAPSHOT_INTERVAL_MS instead of on every tick.
		if (now - savedAt >= SNAPSHOT_INTERVAL_MS) {
			await saveMatch(c);
			savedAt = now;
		}
	}

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

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

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

	for (const player of Object.values(c.vars.players)) {
		const pos = randomPositionInZone(c.vars.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),
		),
	};
}

/**
 * Tells the matchmaker how full this match is. The count only steers new
 * players to a lobby, and the next join or leave sends it again, so a failed
 * update is logged instead of stopping the caller or the tick loop.
 */
async function updateMatchmaker(c: ActorContextOf<typeof battleRoyaleMatch>) {
	const client = c.client<typeof registry>();
	try {
		await client.battleRoyaleMatchmaker.getOrCreate(["main"]).updateMatch({
			matchId: c.vars.matchId,
			connectedPlayerCount: Object.keys(c.vars.players).length,
			isStarted: c.vars.phase !== "lobby",
		});
	} catch (error) {
		c.log.warn({ msg: "updating the matchmaker failed", error });
	}
}

async function claimPendingPlayer(
	c: ActorContextOf<typeof battleRoyaleMatch>,
	playerId: string,
): Promise<boolean> {
	const client = c.client<typeof registry>();
	const result = await client.battleRoyaleMatchmaker
		.getOrCreate(["main"])
		.pendingPlayerConnected({ matchId: c.vars.matchId, playerId });
	return result.accepted;
}

/**
 * Writes the match and every claimed player to SQLite in one transaction, so
 * a crash leaves either the previous save or this one, never part of both.
 */
async function saveMatch(c: ActorContextOf<typeof battleRoyaleMatch>) {
	// Copy the values first, because actions and ticks can run while the
	// transaction waits on SQLite.
	const { tick, phase, lobbyCountdown, winnerId } = c.vars;
	const zone = { ...c.vars.zone };
	const players = Object.entries(c.vars.players).map(([playerId, player]) => {
		const eliminationIndex = c.vars.eliminationOrder.indexOf(playerId);
		return {
			playerId,
			x: player.x,
			y: player.y,
			hp: player.hp,
			alive: player.alive ? 1 : 0,
			placement: player.placement,
			eliminationIndex: eliminationIndex === -1 ? null : eliminationIndex,
		};
	});

	await c.db.transaction(
		async (tx) => {
			await tx.execute(
				"INSERT OR REPLACE INTO match_info (id, tick, phase, lobby_countdown, zone_center_x, zone_center_y, zone_radius, winner_id) VALUES (1, ?, ?, ?, ?, ?, ?, ?)",
				tick,
				phase,
				lobbyCountdown,
				zone.centerX,
				zone.centerY,
				zone.radius,
				winnerId,
			);
			await tx.execute("DELETE FROM players");
			for (const player of players) {
				await tx.execute(
					"INSERT INTO players (player_id, x, y, hp, alive, placement, elimination_index) VALUES (?, ?, ?, ?, ?, ?, ?)",
					player.playerId,
					player.x,
					player.y,
					player.hp,
					player.alive,
					player.placement,
					player.eliminationIndex,
				);
			}
		},
		{ name: "save-match" },
	);
}

function countAlivePlayers(vars: MatchVars): number {
	return Object.values(vars.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.vars.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.vars.matchId,
		tick: c.vars.tick,
		phase: c.vars.phase,
		capacity: LOBBY_CAPACITY,
		playerCount: Object.values(c.vars.players).filter(
			(p) => p.connId !== null,
		).length,
		aliveCount: countAlivePlayers(c.vars),
		winnerId: c.vars.winnerId,
		lobbyCountdown: c.vars.lobbyCountdown,
		worldSize: WORLD_SIZE,
		zone: { ...c.vars.zone },
		players,
	};
}

function findPlayerByConnId(
	vars: MatchVars,
	connId: string,
): [string, PlayerEntry] | null {
	for (const [id, entry] of Object.entries(vars.players)) {
		if (entry.connId === connId) return [id, entry];
	}
	return null;
}
  • onWake starts runMatch as a background task on every start, and onSleep waits for it to save and exit. Connected players keep the match awake. Once everyone leaves, it sleeps, and the next connection resumes it from the last save. The 5 second lobby grace is shorter than the 30 second idle timeout, so the loop always removes the last players before an empty lobby sleeps.
  • 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.
  • Ticks and actions only change memory. Every SNAPSHOT_INTERVAL_MS (1 second), and once more when the Actor stops, saveMatch writes the phase, tick, zone, countdown, and every claimed player in one c.db.transaction, so a crash loses at most about a second of play.
  • 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.

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:

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:

  • findMatch is an action, so the menu awaits its return value. It calls it on a handle without opening a connection, because the player only talks to the match after that.
  • 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

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: open lobbies that players join and leave at any time.
  • Transactions: how the matchmaker reserves seats when requests overlap.
  • Connections: connection params, onConnect, and per-connection state.
Edit this page Last updated November 10, 2026