Arena Game
Build an arena game such as a team deathmatch, a free-for-all, or a Rocket League style match, where players queue for a mode and play a fixed-size match.
What we’ll build:
- An
arenaMatchmakerActor with one queue per mode (1v1,ffa,duo, andsquad) that only creates a match once enough players are waiting. - Teams assigned by the matchmaker before the match exists, or no teams for free-for-all.
- An
arenaMatchActor per match that waits for every player, then runs at 20 ticks per second until a side reaches 10 kills. - Movement that the client owns and the server checks, and hits that only the server resolves.
Install
npm add rivetkit
Create the matchmaker
One arenaMatchmaker Actor, keyed ["main"], holds a waiting pool per mode and builds full matches from it. The pool, matches, and assignments live in its SQLite database:
/*
This matchmaker uses a queue and fill flow.
1. queueForMatch adds players to player_pool for a specific mode.
2. Once enough players are queued for that mode, fillMatch removes them from the pool and creates a match actor.
3. The matchmaker stores assignments and broadcasts assignmentReady so each client can connect to the new match.
4. onDisconnect unqueues pending players so stale queue entries do not stay in the pool.
*/
import { type ActorContextOf, actor, event } from "rivetkit";
import { db, type RawAccess } from "rivetkit/db";
import type { registry } from "../index.ts";
import { MODE_CONFIG, type Mode } from "./config.ts";
export interface ArenaAssignment {
matchId: string;
playerId: string;
teamId: number;
mode: Mode;
connId: string | null;
}
type QueuePlayerRow = {
player_id: string;
conn_id: string | null;
queued_at: number;
};
export const arenaMatchmaker = actor({
options: { name: "Arena - Matchmaker", icon: "crosshairs" },
db: db({
onMigrate: migrateTables,
}),
events: {
assignmentReady: event<ArenaAssignment>(),
queueUpdate: event<{ counts: Record<string, number> }>(),
},
actions: {
queueForMatch: async (c, { mode }: { mode: Mode }) => {
const playerId = crypto.randomUUID();
await c.db.execute(
`INSERT INTO player_pool (player_id, mode, queued_at, conn_id) VALUES (?, ?, ?, ?)`,
playerId,
mode,
Date.now(),
c.conn.id,
);
await broadcastQueueSizes(c);
await fillMatch(c, mode);
return { playerId };
},
getQueueSizes: async (c) => {
const rows = await c.db.execute<{ mode: string; cnt: number }>(
`SELECT mode, COUNT(*) as cnt FROM player_pool GROUP BY mode`,
);
const counts: Record<string, number> = {};
for (const row of rows) {
counts[row.mode] = row.cnt;
}
return counts;
},
getAssignment: async (c, { playerId }: { playerId: string }) => {
const rows = await c.db.execute<{
match_id: string;
player_id: string;
team_id: number;
mode: string;
conn_id: string | null;
}>(
`SELECT * FROM assignments WHERE player_id = ? AND conn_id = ?`,
playerId,
c.conn.id,
);
if (rows.length === 0) return null;
const row = rows[0]!;
return {
matchId: row.match_id,
playerId: row.player_id,
teamId: row.team_id,
mode: row.mode as Mode,
connId: row.conn_id,
};
},
matchCompleted: async (c, { matchId }: { matchId: string }) => {
await c.db.execute(`DELETE FROM matches WHERE match_id = ?`, matchId);
},
},
onDisconnect: async (c, conn) => {
await c.db.execute(`DELETE FROM player_pool WHERE conn_id = ?`, conn.id);
await broadcastQueueSizes(c);
},
});
async function broadcastQueueSizes(c: ActorContextOf<typeof arenaMatchmaker>) {
const rows = await c.db.execute<{ mode: string; cnt: number }>(
`SELECT mode, COUNT(*) as cnt FROM player_pool GROUP BY mode`,
);
const counts: Record<string, number> = {};
for (const row of rows) {
counts[row.mode] = row.cnt;
}
c.broadcast("queueUpdate", { counts });
}
async function fillMatch(c: ActorContextOf<typeof arenaMatchmaker>, mode: Mode) {
const config = MODE_CONFIG[mode];
// Actions run concurrently, so check the count and remove the players in
// one transaction. Two players joining at once can't both fill the last slot.
const queued = await c.db.transaction(
async (tx) => {
const rows = await tx.execute<QueuePlayerRow>(
`SELECT player_id, conn_id, queued_at FROM player_pool WHERE mode = ? ORDER BY queued_at ASC LIMIT ?`,
mode,
config.capacity,
);
if (rows.length < config.capacity) return [];
for (const row of rows) {
await tx.execute(
`DELETE FROM player_pool WHERE player_id = ?`,
row.player_id,
);
}
return rows;
},
{ name: "claim-players" },
);
if (queued.length === 0) return;
const matchId = crypto.randomUUID();
const assignedPlayers = queued.map((row, idx) => ({
playerId: row.player_id,
connId: row.conn_id,
teamId: config.teams > 0 ? idx % config.teams : -1,
}));
// Create the match outside the transaction, because a transaction must not
// wait on another actor.
const client = c.client<typeof registry>();
try {
await client.arenaMatch.create([matchId], {
input: {
matchId,
mode,
capacity: config.capacity,
assignedPlayers: assignedPlayers.map((ap) => ({
playerId: ap.playerId,
teamId: ap.teamId,
})),
},
});
} catch (error) {
await returnToPool(c, mode, queued);
throw error;
}
await c.db.transaction(
async (tx) => {
await tx.execute(
`INSERT INTO matches (match_id, mode, capacity, created_at) VALUES (?, ?, ?, ?)`,
matchId,
mode,
config.capacity,
Date.now(),
);
for (const ap of assignedPlayers) {
await tx.execute(
`INSERT INTO assignments (player_id, match_id, team_id, mode, conn_id) VALUES (?, ?, ?, ?, ?)`,
ap.playerId,
matchId,
ap.teamId,
mode,
ap.connId,
);
}
},
{ name: "save-match" },
);
await broadcastQueueSizes(c);
for (const ap of assignedPlayers) {
c.broadcast("assignmentReady", {
matchId,
playerId: ap.playerId,
teamId: ap.teamId,
mode,
connId: ap.connId,
} satisfies ArenaAssignment);
}
}
/** Puts claimed players back at their original place in the pool. */
async function returnToPool(
c: ActorContextOf<typeof arenaMatchmaker>,
mode: Mode,
players: QueuePlayerRow[],
) {
// A player who left while the match was being created stays out of the
// pool, because their disconnect found no row to remove.
const stillConnected = players.filter(
(player) => player.conn_id !== null && c.conns.has(player.conn_id),
);
await c.db.transaction(
async (tx) => {
for (const player of stillConnected) {
await tx.execute(
`INSERT INTO player_pool (player_id, mode, queued_at, conn_id) VALUES (?, ?, ?, ?)`,
player.player_id,
mode,
player.queued_at,
player.conn_id,
);
}
},
{ name: "return-players" },
);
await broadcastQueueSizes(c);
}
async function migrateTables(dbHandle: RawAccess) {
await dbHandle.execute(`
CREATE TABLE IF NOT EXISTS player_pool (
player_id TEXT PRIMARY KEY,
mode TEXT NOT NULL,
queued_at INTEGER NOT NULL,
conn_id TEXT
)
`);
await dbHandle.execute(`
CREATE TABLE IF NOT EXISTS matches (
match_id TEXT PRIMARY KEY,
mode TEXT NOT NULL,
capacity INTEGER NOT NULL,
created_at INTEGER NOT NULL
)
`);
await dbHandle.execute(`
CREATE TABLE IF NOT EXISTS assignments (
player_id TEXT PRIMARY KEY,
match_id TEXT NOT NULL,
team_id INTEGER NOT NULL,
mode TEXT NOT NULL,
conn_id TEXT
)
`);
}
queueForMatchis an action. It creates theplayerId, adds the player toplayer_poolwithc.conn.id, sends the new counts asqueueUpdate, and callsfillMatch. The match arrives as an event, because nobody knows how long the wait will be.- Actions run at the same time, so
fillMatchchecks the count and removes the players who have waited longest in onec.db.transaction. Two players joining at once can’t both fill the last slot. fillMatchassigns teams by position (idx % teams, or-1for free-for-all). After the transaction commits, it creates thearenaMatchActor with the roster as input, saves the match and itsassignments, and broadcastsassignmentReady. If creating the match fails,returnToPoolputs the players back in their old places.onDisconnectdeletes the player’s row, so a player who closes the tab leaves the pool.
assignmentReady is broadcast to every client connected to the matchmaker, and the playerId in it is enough to connect as that player. In production, send each assignment only to its owner with conn.send(...) (the row already stores conn_id), and check a signed token in the match.
Create the match
Each match is its own arenaMatch Actor, keyed by the match id. It starts with the full roster and goes live once everyone has connected:
db: db({
onMigrate: async (db) => {
await db.execute(`
CREATE TABLE IF NOT EXISTS match_info (
id INTEGER PRIMARY KEY CHECK (id = 1),
match_id TEXT NOT NULL,
mode TEXT NOT NULL,
capacity INTEGER NOT NULL,
tick INTEGER NOT NULL DEFAULT 0,
phase TEXT NOT NULL DEFAULT 'waiting',
winner_team INTEGER,
winner_player_id TEXT
)
`);
await db.execute(`
CREATE TABLE IF NOT EXISTS roster (
player_id TEXT PRIMARY KEY,
team_id INTEGER NOT NULL,
x REAL NOT NULL,
y REAL NOT NULL,
score INTEGER NOT NULL DEFAULT 0
)
`);
},
}),
onCreate: async (
c,
input: {
matchId: string;
mode: Mode;
capacity: number;
assignedPlayers: AssignedPlayer[];
},
) => {
// Save the settings and the roster once, with a random spawn point for
// each player, so every start of this match sees the same players.
await c.db.transaction(
async (tx) => {
await tx.execute(
"INSERT INTO match_info (id, match_id, mode, capacity) VALUES (1, ?, ?, ?)",
input.matchId,
input.mode,
input.capacity,
);
for (const ap of input.assignedPlayers) {
await tx.execute(
"INSERT INTO roster (player_id, team_id, x, y) VALUES (?, ?, ?, ?)",
ap.playerId,
ap.teamId,
Math.random() * WORLD_SIZE,
Math.random() * WORLD_SIZE,
);
}
},
{ name: "create-match" },
);
},
createVars: async (c): Promise<MatchVars> => {
const [info] = await c.db.execute<MatchInfoRow>(
"SELECT match_id, mode, capacity, tick, phase, winner_team, winner_player_id FROM match_info WHERE id = 1",
);
const roster = await c.db.execute<RosterRow>(
"SELECT player_id, team_id, x, y, score FROM roster",
);
// The live match runs in memory and starts from the last save. No
// connection survives a restart, so every player starts unbound.
const players: Record<string, PlayerEntry> = {};
for (const row of roster) {
players[row.player_id] = {
connId: null,
teamId: row.team_id,
color: getPlayerColor(row.player_id),
x: row.x,
y: row.y,
lastPositionAt: Date.now(),
alive: true,
score: row.score,
};
}
return {
matchId: info!.match_id,
mode: info!.mode,
capacity: info!.capacity,
tick: info!.tick,
phase: info!.phase,
players,
winnerTeam: info!.winner_team,
winnerPlayerId: info!.winner_player_id,
loop: null,
};
},
onConnect: (c, conn) => {
const playerId = (conn.params as { playerId?: string })?.playerId;
if (!playerId) return;
const player = c.vars.players[playerId];
if (!player) {
conn.disconnect("invalid_player");
return;
}
player.connId = conn.id;
if (c.vars.phase === "waiting") {
const allConnected = Object.values(c.vars.players).every(
(p) => p.connId !== null,
);
if (allConnected) {
c.vars.phase = "live";
}
}
broadcastSnapshot(c);
},
onCreateruns once and saves the match settings and the roster from the input, with a random spawn point for each player, into the match’s SQLite database.createVarsloads them on every start, with the phase, tick, positions, and scores from the last save, intoc.vars. Every player starts without a connection, because none survives a restart. The roster is fixed, soonConnectdoesn’t need to ask the matchmaker. An id that isn’t on the roster is disconnected withinvalid_player.- The match stays in
waitinguntil every assigned player has a connection, then switches tolive. Nobody starts a 4v4 with three players. - A disconnect only clears the player’s connection id. Their seat and score stay, so they can reconnect into the same match.
Validate movement and score hits
The match runs a 20 tick per second loop, and checks every move and shot that arrives between ticks:
import { type ActorContextOf, actor, event, UserError } from "rivetkit";
import { db } from "rivetkit/db";
import { interval } from "rivetkit/utils";
import type { registry } from "../index.ts";
import { getPlayerColor } from "../player-color.ts";
import {
MAX_SPEED,
type Mode,
SCORE_LIMIT,
SHOOT_ANGLE,
SHOOT_RANGE,
SNAPSHOT_INTERVAL_MS,
TICK_MS,
WORLD_SIZE,
} from "./config.ts";
interface PlayerEntry {
connId: string | null;
teamId: number;
color: string;
x: number;
y: number;
lastPositionAt: number;
alive: boolean;
score: number;
}
interface MatchVars {
matchId: string;
mode: Mode;
capacity: number;
tick: number;
phase: "waiting" | "live" | "finished";
players: Record<string, PlayerEntry>;
winnerTeam: number | null;
winnerPlayerId: string | null;
// `onWake` stores its loop here so `onSleep` can wait for it.
loop: Promise<void> | null;
}
interface AssignedPlayer {
playerId: string;
teamId: number;
}
type MatchInfoRow = {
match_id: string;
mode: Mode;
capacity: number;
tick: number;
phase: MatchVars["phase"];
winner_team: number | null;
winner_player_id: string | null;
};
type RosterRow = {
player_id: string;
team_id: number;
x: number;
y: number;
score: number;
};
export const arenaMatch = actor({
options: { name: "Arena - Match", icon: "crosshairs" },
events: {
snapshot: event<Snapshot>(),
shoot: event<ShootEvent>(),
},
db: db({
onMigrate: async (db) => {
await db.execute(`
CREATE TABLE IF NOT EXISTS match_info (
id INTEGER PRIMARY KEY CHECK (id = 1),
match_id TEXT NOT NULL,
mode TEXT NOT NULL,
capacity INTEGER NOT NULL,
tick INTEGER NOT NULL DEFAULT 0,
phase TEXT NOT NULL DEFAULT 'waiting',
winner_team INTEGER,
winner_player_id TEXT
)
`);
await db.execute(`
CREATE TABLE IF NOT EXISTS roster (
player_id TEXT PRIMARY KEY,
team_id INTEGER NOT NULL,
x REAL NOT NULL,
y REAL NOT NULL,
score INTEGER NOT NULL DEFAULT 0
)
`);
},
}),
onCreate: async (
c,
input: {
matchId: string;
mode: Mode;
capacity: number;
assignedPlayers: AssignedPlayer[];
},
) => {
// Save the settings and the roster once, with a random spawn point for
// each player, so every start of this match sees the same players.
await c.db.transaction(
async (tx) => {
await tx.execute(
"INSERT INTO match_info (id, match_id, mode, capacity) VALUES (1, ?, ?, ?)",
input.matchId,
input.mode,
input.capacity,
);
for (const ap of input.assignedPlayers) {
await tx.execute(
"INSERT INTO roster (player_id, team_id, x, y) VALUES (?, ?, ?, ?)",
ap.playerId,
ap.teamId,
Math.random() * WORLD_SIZE,
Math.random() * WORLD_SIZE,
);
}
},
{ name: "create-match" },
);
},
createVars: async (c): Promise<MatchVars> => {
const [info] = await c.db.execute<MatchInfoRow>(
"SELECT match_id, mode, capacity, tick, phase, winner_team, winner_player_id FROM match_info WHERE id = 1",
);
const roster = await c.db.execute<RosterRow>(
"SELECT player_id, team_id, x, y, score FROM roster",
);
// The live match runs in memory and starts from the last save. No
// connection survives a restart, so every player starts unbound.
const players: Record<string, PlayerEntry> = {};
for (const row of roster) {
players[row.player_id] = {
connId: null,
teamId: row.team_id,
color: getPlayerColor(row.player_id),
x: row.x,
y: row.y,
lastPositionAt: Date.now(),
alive: true,
score: row.score,
};
}
return {
matchId: info!.match_id,
mode: info!.mode,
capacity: info!.capacity,
tick: info!.tick,
phase: info!.phase,
players,
winnerTeam: info!.winner_team,
winnerPlayerId: info!.winner_player_id,
loop: null,
};
},
onConnect: (c, conn) => {
const playerId = (conn.params as { playerId?: string })?.playerId;
if (!playerId) return;
const player = c.vars.players[playerId];
if (!player) {
conn.disconnect("invalid_player");
return;
}
player.connId = conn.id;
if (c.vars.phase === "waiting") {
const allConnected = Object.values(c.vars.players).every(
(p) => p.connId !== null,
);
if (allConnected) {
c.vars.phase = "live";
}
}
broadcastSnapshot(c);
},
onDestroy: async (c) => {
// `c.abortSignal` also fires on destroy. Let the loop finish its last
// save before the database closes.
await c.vars.loop;
const client = c.client<typeof registry>();
await client.arenaMatchmaker
.getOrCreate(["main"])
.matchCompleted({ matchId: c.vars.matchId });
},
onDisconnect: (c, conn) => {
const found = findPlayerByConnId(c.vars, conn.id);
if (!found) return;
const [, player] = found;
player.connId = null;
broadcastSnapshot(c);
},
// `onWake` runs on every start, so the loop restarts after a sleep or crash
onWake: (c) => {
c.vars.loop = runMatch(c).catch((error) => {
c.log.error({ msg: "match loop failed", error });
});
},
// `c.abortSignal` fires on shutdown, so the loop saves and exits on its own
onSleep: async (c) => {
await c.vars.loop;
},
actions: {
updatePosition: (c, input: { x: number; y: number }) => {
const found = findPlayerByConnId(c.vars, c.conn.id);
if (!found) {
throw new UserError("player not found", {
code: "player_not_found",
});
}
const [, player] = found;
if (!player.alive) {
throw new UserError("player is not alive", {
code: "not_alive",
});
}
const now = Date.now();
const elapsed = Math.max(0, (now - player.lastPositionAt) / 1000);
const maxDist = MAX_SPEED * elapsed;
const dx = input.x - player.x;
const dy = input.y - player.y;
const dist = Math.sqrt(dx * dx + dy * dy);
let newX: number;
let newY: number;
if (dist > maxDist && dist > 0) {
newX = player.x + (dx / dist) * maxDist;
newY = player.y + (dy / dist) * maxDist;
} else {
newX = input.x;
newY = input.y;
}
player.x = Math.max(0, Math.min(WORLD_SIZE, newX));
player.y = Math.max(0, Math.min(WORLD_SIZE, newY));
player.lastPositionAt = now;
},
shoot: (c, input: { dirX: number; dirY: number }) => {
const found = findPlayerByConnId(c.vars, c.conn.id);
if (!found) {
throw new UserError("player not found", {
code: "player_not_found",
});
}
const [shooterId, shooter] = found;
if (!shooter.alive) {
throw new UserError("player is not alive", {
code: "not_alive",
});
}
if (c.vars.phase !== "live") {
throw new UserError("match is not live", { code: "not_live" });
}
const mag = Math.sqrt(
input.dirX * input.dirX + input.dirY * input.dirY,
);
if (mag === 0) return;
const ndx = input.dirX / mag;
const ndy = input.dirY / mag;
let closestId: string | null = null;
let closestDist = Infinity;
for (const [targetId, target] of Object.entries(c.vars.players)) {
if (targetId === shooterId) continue;
if (!target.alive) continue;
if (shooter.teamId >= 0 && target.teamId === shooter.teamId)
continue;
const tx = target.x - shooter.x;
const ty = target.y - shooter.y;
const targetDist = Math.sqrt(tx * tx + ty * ty);
if (targetDist > SHOOT_RANGE || targetDist === 0) continue;
const dot = (tx / targetDist) * ndx + (ty / targetDist) * ndy;
const angle = Math.acos(Math.max(-1, Math.min(1, dot)));
if (angle > SHOOT_ANGLE) continue;
if (targetDist < closestDist) {
closestDist = targetDist;
closestId = targetId;
}
}
if (closestId) {
const victim = c.vars.players[closestId]!;
shooter.score += 1;
victim.x = Math.random() * WORLD_SIZE;
victim.y = Math.random() * WORLD_SIZE;
victim.lastPositionAt = Date.now();
}
const shootEvent: ShootEvent = {
shooterId,
fromX: shooter.x,
fromY: shooter.y,
dirX: ndx,
dirY: ndy,
hitPlayerId: closestId,
};
c.broadcast("shoot", shootEvent);
checkWinCondition(c);
if ((c.vars.phase as string) === "finished") {
broadcastSnapshot(c);
}
},
getSnapshot: (c) => buildSnapshot(c),
},
});
function checkWinCondition(c: ActorContextOf<typeof arenaMatch>) {
if (c.vars.phase !== "live") return;
if (c.vars.mode === "ffa") {
for (const [id, player] of Object.entries(c.vars.players)) {
if (player.score >= SCORE_LIMIT) {
c.vars.phase = "finished";
c.vars.winnerPlayerId = id;
return;
}
}
} else {
const teamScores: Record<number, number> = {};
for (const player of Object.values(c.vars.players)) {
teamScores[player.teamId] =
(teamScores[player.teamId] ?? 0) + player.score;
}
for (const [teamId, score] of Object.entries(teamScores)) {
if (score >= SCORE_LIMIT) {
c.vars.phase = "finished";
c.vars.winnerTeam = Number(teamId);
return;
}
}
}
}
function broadcastSnapshot(c: ActorContextOf<typeof arenaMatch>) {
c.broadcast("snapshot", buildSnapshot(c));
}
/** Steps the match every `TICK_MS` until the actor starts shutting down. */
async function runMatch(c: ActorContextOf<typeof arenaMatch>): Promise<void> {
const tick = interval(TICK_MS);
let savedAt = Date.now();
while (!c.aborted) {
await tick();
if (c.aborted) break;
// Ticks and actions only change memory. Save to SQLite every
// SNAPSHOT_INTERVAL_MS in every phase, so a finished match stays
// finished after a crash.
const now = Date.now();
if (now - savedAt >= SNAPSHOT_INTERVAL_MS) {
await saveMatch(c);
savedAt = now;
}
if (c.vars.phase !== "live") continue;
c.vars.tick += 1;
checkWinCondition(c);
broadcastSnapshot(c);
}
// Save once more when the Actor stops, so a clean restart loses nothing.
await saveMatch(c);
}
/**
* Writes the phase, tick, winner, and every player's position and score to
* SQLite in one transaction, so a crash leaves either the previous save or
* this one, never part of both.
*/
async function saveMatch(c: ActorContextOf<typeof arenaMatch>) {
// Copy the values first, because actions such as updatePosition can run
// while the transaction waits on SQLite.
const { tick, phase, winnerTeam, winnerPlayerId } = c.vars;
const players = Object.entries(c.vars.players).map(
([playerId, player]) => ({
playerId,
x: player.x,
y: player.y,
score: player.score,
}),
);
await c.db.transaction(
async (tx) => {
await tx.execute(
"UPDATE match_info SET tick = ?, phase = ?, winner_team = ?, winner_player_id = ? WHERE id = 1",
tick,
phase,
winnerTeam,
winnerPlayerId,
);
for (const player of players) {
await tx.execute(
"UPDATE roster SET x = ?, y = ?, score = ? WHERE player_id = ?",
player.x,
player.y,
player.score,
player.playerId,
);
}
},
{ name: "save-match" },
);
}
interface Snapshot {
matchId: string;
mode: Mode;
capacity: number;
tick: number;
phase: "waiting" | "live" | "finished";
winnerTeam: number | null;
winnerPlayerId: string | null;
worldSize: number;
scoreLimit: number;
players: Record<
string,
{ x: number; y: number; teamId: number; color: string; score: number }
>;
}
interface ShootEvent {
shooterId: string;
fromX: number;
fromY: number;
dirX: number;
dirY: number;
hitPlayerId: string | null;
}
function buildSnapshot(c: ActorContextOf<typeof arenaMatch>): Snapshot {
const players: Record<
string,
{ x: number; y: number; teamId: number; color: string; score: number }
> = {};
for (const [id, entry] of Object.entries(c.vars.players)) {
players[id] = {
x: entry.x,
y: entry.y,
teamId: entry.teamId,
color: entry.color,
score: entry.score,
};
}
return {
matchId: c.vars.matchId,
mode: c.vars.mode,
capacity: c.vars.capacity,
tick: c.vars.tick,
phase: c.vars.phase,
winnerTeam: c.vars.winnerTeam,
winnerPlayerId: c.vars.winnerPlayerId,
worldSize: WORLD_SIZE,
scoreLimit: SCORE_LIMIT,
players,
};
}
function findPlayerByConnId(
vars: MatchVars,
connId: string,
): [string, PlayerEntry] | null {
for (const [id, entry] of Object.entries(vars.players)) {
if (entry.connId === connId) return [id, entry];
}
return null;
}
onWakestartsrunMatchas a background task on every start, andonSleepwaits for it to save and exit. Connected players keep the match awake. Once everyone leaves, it sleeps, and the next connection resumes it from the last save.interval(TICK_MS)ticks every 50 ms, twice as often as the battle royale, because arena fights are short and close. Ticks are skipped until the match islive. Each live tick checks the win condition and broadcasts onesnapshot.- Ticks and actions only change memory. Every
SNAPSHOT_INTERVAL_MS(1 second) in every phase, and once more when the Actor stops,saveMatchwrites the phase, tick, winner, positions, and scores in onec.db.transaction. A crash loses at most about a second of play, and a finished match stays finished. - The client owns movement.
updatePositionlimits each move toMAX_SPEEDtimes the time since the last update and keeps the player inside the world, so a client that teleports only moves as far as it could have run. - The server owns combat.
shootskips teammates, finds the closest enemy withinSHOOT_RANGEandSHOOT_ANGLEof the aim, gives the shooter a point, respawns the victim at a random spot, and broadcastsshoot. checkWinConditionends a free-for-all when one player reachesSCORE_LIMIT, and a team match when a team’s combined score does. When the match Actor is destroyed,onDestroycallsmatchCompletedso the matchmaker deletes its row.
For a Rocket League style match, the ball is shared by both teams, so simulate it on the server with @dimforge/rapier3d and let each client predict only its own car. Also limit how often each connection may call updatePosition and shoot. The example client sends positions at most 20 times per second.
Register the Actors
Add both Actors to the registry and start it:
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,
},
});
The example registers every game type in one registry. An arena game only needs arenaMatchmaker and arenaMatch. Each Actor file imports type { registry } so that c.client<typeof registry>() is typed when the matchmaker creates a match and the match reports back.
Connect players
The menu queues for a mode and waits for an assignment, then the game connects to the match:
const queueForMatch = async () => {
setStatus("queuing");
setError("");
abortRef.current = false;
matchedRef.current = false;
try {
const mm = client.arenaMatchmaker.getOrCreate(["main"]).connect();
mmRef.current = mm;
let myPlayerId: string | null = null;
let matched = false;
const resolveMatch = (assignment: ArenaMatchInfo) => {
if (matched || abortRef.current) return;
matched = true;
matchedRef.current = true;
setStatus("matched");
setTimeout(async () => {
// Don't destroy bots - they need to connect to the match.
await cleanup(false);
onReady(assignment);
}, 1500);
};
mm.on("queueUpdate", (data) => {
setQueueCount(data.counts[mode] ?? 0);
});
setStatus("queued");
const response = await mm.queueForMatch({
mode,
});
if (!response?.playerId || abortRef.current)
throw new Error("Failed to queue");
myPlayerId = response.playerId;
const sizes = await mm.getQueueSizes();
if (!abortRef.current) setQueueCount(sizes[mode] ?? 0);
const assignment = await waitForAssignment<ArenaMatchInfo>(
mm,
myPlayerId,
);
resolveMatch(assignment);
} catch (err) {
if (abortRef.current) return;
await cleanup();
setError(err instanceof Error ? err.message : String(err));
setStatus("error");
}
};
interface Assignment {
playerId: string;
}
type AssignmentConnection = {
getAssignment: (input: { playerId: string }) => Promise<Assignment | null>;
on: (
event: "assignmentReady",
handler: (assignment: Assignment) => void,
) => (() => void) | undefined;
};
function normalizeError(err: unknown): Error {
if (err instanceof Error) return err;
return new Error(String(err));
}
export async function waitForAssignment<T extends Assignment>(
mm: AssignmentConnection,
playerId: string,
timeoutMs = 120_000,
): Promise<T> {
const existing = await readAssignment<T>(mm, playerId);
if (existing) return existing;
return await new Promise<T>((resolve, reject) => {
let settled = false;
let timeout: number | null = null;
let off: (() => void) | undefined;
const settle = (fn: () => void) => {
if (settled) return;
settled = true;
if (timeout !== null) window.clearTimeout(timeout);
off?.();
fn();
};
off = mm.on("assignmentReady", (next) => {
if (next.playerId !== playerId) return;
settle(() => resolve(next as T));
});
timeout = window.setTimeout(() => {
settle(() => reject(new Error("Timed out waiting for assignment")));
}, timeoutMs);
void readAssignment<T>(mm, playerId)
.then((next) => {
if (!next) return;
settle(() => resolve(next));
})
.catch((err) => {
settle(() => reject(normalizeError(err)));
});
});
}
async function readAssignment<T extends Assignment>(
mm: AssignmentConnection,
playerId: string,
): Promise<T | null> {
return (await mm.getAssignment({ playerId })) as T | null;
}
import type { ArenaMatchConn } from "../../actor-types.ts";
import type { GameClient } from "../../client.ts";
import type { ArenaMatchInfo } from "./menu.tsx";
const PLAYER_RADIUS = 12;
const LERP_FACTOR = 0.2;
const GRID_SPACING = 50;
const SEND_RATE_MS = 50; // ~20Hz position updates
const MOVE_SPEED = 200; // pixels per second (matches server MAX_SPEED)
const SHOT_LINE_DURATION = 150; // ms to show shot line
const RUBBER_BAND_THRESHOLD = 40; // pixels before snapping local player to server
interface ShotLine {
fromX: number;
fromY: number;
toX: number;
toY: number;
hit: boolean;
createdAt: number;
}
export class ArenaGame {
private stopped = false;
private rafId = 0;
private worldSize = 600;
private targets: Record<
string,
{ x: number; y: number; teamId: number; color: string; score: number }
> = {};
private display: Record<string, { x: number; y: number }> = {};
private keys: Record<string, boolean> = {};
private phase: "waiting" | "live" | "finished" = "waiting";
private scoreLimit = 10;
private winnerTeam: number | null = null;
private winnerPlayerId: string | null = null;
private shotLines: ShotLine[] = [];
private lastSendTime = 0;
private localX = 300;
private localY = 300;
private lastFrameTime = 0;
private botInterval = 0;
private conn: ArenaMatchConn;
constructor(
private canvas: HTMLCanvasElement | null,
client: GameClient,
private matchInfo: ArenaMatchInfo,
private options: { bot?: boolean } = {},
) {
this.conn = client.arenaMatch
.get([matchInfo.matchId], {
params: { playerId: matchInfo.playerId },
})
.connect();
this.conn.on("snapshot", (snap) => {
this.worldSize = snap.worldSize;
this.scoreLimit = snap.scoreLimit;
this.phase = snap.phase;
this.winnerTeam = snap.winnerTeam;
this.winnerPlayerId = snap.winnerPlayerId;
for (const [id, data] of Object.entries(snap.players)) {
this.targets[id] = data;
if (id === this.matchInfo.playerId) {
// Rubber-band local player if server disagrees significantly.
const dx = data.x - this.localX;
const dy = data.y - this.localY;
if (Math.sqrt(dx * dx + dy * dy) > RUBBER_BAND_THRESHOLD) {
this.localX = data.x;
this.localY = data.y;
}
} else if (!this.display[id]) {
this.display[id] = { x: data.x, y: data.y };
}
}
// Remove players no longer in snapshot.
for (const id of Object.keys(this.targets)) {
if (!snap.players[id]) {
delete this.targets[id];
delete this.display[id];
}
}
});
this.conn.on("shoot", (shot) => {
const lineLen = 300;
this.shotLines.push({
fromX: shot.fromX,
fromY: shot.fromY,
toX: shot.fromX + shot.dirX * lineLen,
toY: shot.fromY + shot.dirY * lineLen,
hit: shot.hitPlayerId !== null,
createdAt: performance.now(),
});
});
if (options.bot) {
this.botInterval = window.setInterval(() => {
if (this.phase !== "live") return;
this.localX += (Math.random() - 0.5) * 40;
this.localY += (Math.random() - 0.5) * 40;
this.localX = Math.max(
0,
Math.min(this.worldSize, this.localX),
);
this.localY = Math.max(
0,
Math.min(this.worldSize, this.localY),
);
this.conn
.updatePosition({ x: this.localX, y: this.localY })
.catch(() => {});
if (Math.random() < 0.2) {
const angle = Math.random() * Math.PI * 2;
this.conn
.shoot({ dirX: Math.cos(angle), dirY: Math.sin(angle) })
.catch(() => {});
}
}, 100);
} else if (canvas) {
canvas.addEventListener("click", this.onClick);
window.addEventListener("keydown", this.onKeyDown);
window.addEventListener("keyup", this.onKeyUp);
this.lastFrameTime = performance.now();
this.rafId = requestAnimationFrame(this.draw);
}
}
destroy() {
this.stopped = true;
cancelAnimationFrame(this.rafId);
clearInterval(this.botInterval);
if (!this.options.bot && this.canvas) {
this.canvas.removeEventListener("click", this.onClick);
window.removeEventListener("keydown", this.onKeyDown);
window.removeEventListener("keyup", this.onKeyUp);
}
this.conn.dispose().catch(() => {});
}
private onKeyDown = (e: KeyboardEvent) => {
this.keys[e.key] = true;
};
private onKeyUp = (e: KeyboardEvent) => {
this.keys[e.key] = false;
};
private onClick = (e: MouseEvent) => {
if (this.phase !== "live" || !this.canvas) return;
const rect = this.canvas.getBoundingClientRect();
const sx = this.canvas.width / this.worldSize;
const sy = this.canvas.height / this.worldSize;
const clickX = (e.clientX - rect.left) / sx;
const clickY = (e.clientY - rect.top) / sy;
const dx = clickX - this.localX;
const dy = clickY - this.localY;
const mag = Math.sqrt(dx * dx + dy * dy);
if (mag === 0) return;
this.conn.shoot({ dirX: dx / mag, dirY: dy / mag }).catch(() => {});
};
private draw = () => {
if (this.stopped) return;
const now = performance.now();
const dt = (now - this.lastFrameTime) / 1000;
this.lastFrameTime = now;
const canvas = this.canvas!;
const ctx = canvas.getContext("2d")!;
const sx = canvas.width / this.worldSize;
const sy = canvas.height / this.worldSize;
// Client-side movement.
if (this.phase === "live") {
let ix = 0;
let iy = 0;
if (this.keys.w || this.keys.W || this.keys.ArrowUp) iy -= 1;
if (this.keys.s || this.keys.S || this.keys.ArrowDown) iy += 1;
if (this.keys.a || this.keys.A || this.keys.ArrowLeft) ix -= 1;
if (this.keys.d || this.keys.D || this.keys.ArrowRight) ix += 1;
if (ix !== 0 || iy !== 0) {
const mag = Math.sqrt(ix * ix + iy * iy);
this.localX = Math.max(
0,
Math.min(
this.worldSize,
this.localX + (ix / mag) * MOVE_SPEED * dt,
),
);
this.localY = Math.max(
0,
Math.min(
this.worldSize,
this.localY + (iy / mag) * MOVE_SPEED * dt,
),
);
}
// Send position to server at capped rate.
if (now - this.lastSendTime >= SEND_RATE_MS) {
this.lastSendTime = now;
this.conn
.updatePosition({ x: this.localX, y: this.localY })
.catch(() => {});
}
}
// Clear.
ctx.fillStyle = "#000000";
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Grid.
ctx.strokeStyle = "#2c2c2e";
ctx.lineWidth = 1;
ctx.beginPath();
for (let x = 0; x <= this.worldSize; x += GRID_SPACING) {
const px = x * sx;
ctx.moveTo(px, 0);
ctx.lineTo(px, canvas.height);
}
for (let y = 0; y <= this.worldSize; y += GRID_SPACING) {
const py = y * sy;
ctx.moveTo(0, py);
ctx.lineTo(canvas.width, py);
}
ctx.stroke();
// Shot lines.
this.shotLines = this.shotLines.filter(
(s) => now - s.createdAt < SHOT_LINE_DURATION,
);
for (const shot of this.shotLines) {
const alpha = 1 - (now - shot.createdAt) / SHOT_LINE_DURATION;
ctx.strokeStyle = shot.hit
? `rgba(255, 59, 48, ${alpha})`
: `rgba(255, 255, 255, ${alpha * 0.4})`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(shot.fromX * sx, shot.fromY * sy);
ctx.lineTo(shot.toX * sx, shot.toY * sy);
ctx.stroke();
}
// Players.
for (const [id, target] of Object.entries(this.targets)) {
const isMe = id === this.matchInfo.playerId;
let px: number;
let py: number;
if (isMe) {
px = this.localX * sx;
py = this.localY * sy;
} else {
const d = this.display[id];
if (!d) continue;
d.x += (target.x - d.x) * LERP_FACTOR;
d.y += (target.y - d.y) * LERP_FACTOR;
px = d.x * sx;
py = d.y * sy;
}
const color = target.color;
ctx.beginPath();
ctx.arc(px, py, PLAYER_RADIUS, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
if (isMe) {
ctx.lineWidth = 2;
ctx.strokeStyle = "#ffffff";
ctx.stroke();
}
// Label: short ID + score.
ctx.fillStyle = "#ffffff";
ctx.font = "11px sans-serif";
ctx.textAlign = "center";
ctx.fillText(
`${id.slice(0, 6)} [${target.score}/${this.scoreLimit}]`,
px,
py - PLAYER_RADIUS - 4,
);
}
// Score overlay.
ctx.fillStyle = "rgba(0, 0, 0, 0.6)";
ctx.fillRect(0, 0, canvas.width, 28);
ctx.fillStyle = "#ffffff";
ctx.font = "12px sans-serif";
ctx.textAlign = "left";
const myData = this.targets[this.matchInfo.playerId];
const scoreText = myData
? `Score: ${myData.score}/${this.scoreLimit}`
: "";
ctx.fillText(`${this.phase.toUpperCase()} ${scoreText}`, 8, 18);
// Phase overlay.
if (this.phase === "waiting") {
ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#ffffff";
ctx.font = "24px sans-serif";
ctx.textAlign = "center";
ctx.fillText(
"Waiting for players...",
canvas.width / 2,
canvas.height / 2,
);
} else if (this.phase === "finished") {
ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#ffffff";
ctx.font = "24px sans-serif";
ctx.textAlign = "center";
let winText = "Match Over";
if (this.winnerPlayerId) {
winText =
this.winnerPlayerId === this.matchInfo.playerId
? "You Win!"
: `Winner: ${this.winnerPlayerId.slice(0, 8)}`;
} else if (this.winnerTeam !== null) {
winText =
this.winnerTeam === this.matchInfo.teamId
? "Your Team Wins!"
: `Team ${this.winnerTeam} Wins!`;
}
ctx.fillText(winText, canvas.width / 2, canvas.height / 2);
}
this.rafId = requestAnimationFrame(this.draw);
};
}
- The menu keeps its matchmaker connection open while queued, because the assignment arrives as an event.
queueUpdatedrives the “players in queue” counter. waitForAssignmentlistens forassignmentReadywith this player’s id and also callsgetAssignmentonce, in case the event fired before the listener was attached.getAssignmentonly returns a row whoseconn_idmatches the caller’s connection.- The game connects with
arenaMatch.get(...)andplayerIdinparams, the same id the matchmaker put on the roster. - The client moves its own player every frame and sends
updatePositionat most every 50 ms. If asnapshotputs it more than 40 pixels from where it thinks it is, it snaps to the server’s position.
Try it
git clone https://github.com/rivet-dev/rivet.git
cd rivet/examples/multiplayer-game-patterns
pnpm install
pnpm dev
Open http://localhost:5173, pick Arena, choose FFA, and click Queue. The counter shows 1/4 players in queue. Click Add Bot three times. The menu shows Match Found!, then the match goes live once all four players connect. Move with WASD and click to shoot. The first player to 10 kills wins. Pick Duo or Squad to see teams.
Next steps
- Ranked Game: pair players by rating instead of first come, first served.
- Transactions: how the matchmaker claims players when requests overlap.
- Events: broadcast to everyone or send to one connection.