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, queue } from "rivetkit";
import { db, type RawAccess } from "rivetkit/db";
import type { registry } from "../index.ts";
import { MODE_CONFIG, type Mode } from "./config.ts";
export interface ArenaAssignment {
matchId: string;
playerId: string;
teamId: number;
mode: Mode;
connId: string | null;
}
type QueuePlayerRow = {
player_id: string;
conn_id: string | null;
};
export const arenaMatchmaker = actor({
options: { name: "Arena - Matchmaker", icon: "crosshairs" },
db: db({
onMigrate: migrateTables,
}),
queues: {
queueForMatch: queue<{
mode: Mode;
playerId: string;
connId: string;
}>(),
unqueueForMatch: queue<{ connId: string }>(),
matchCompleted: queue<{ matchId: string }>(),
},
events: {
assignmentReady: event<ArenaAssignment>(),
queueUpdate: event<{ counts: Record<string, number> }>(),
},
actions: {
queueForMatch: async (c, { mode }: { mode: Mode }) => {
const playerId = crypto.randomUUID();
await c.queue.send("queueForMatch", {
mode,
playerId,
connId: c.conn.id,
});
return { playerId };
},
getQueueSizes: async (c) => {
const rows = await c.db.execute<{ mode: string; cnt: number }>(
`SELECT mode, COUNT(*) as cnt FROM player_pool GROUP BY mode`,
);
const counts: Record<string, number> = {};
for (const row of rows) {
counts[row.mode] = row.cnt;
}
return counts;
},
getAssignment: async (c, { playerId }: { playerId: string }) => {
const rows = await c.db.execute<{
match_id: string;
player_id: string;
team_id: number;
mode: string;
conn_id: string | null;
}>(
`SELECT * FROM assignments WHERE player_id = ? AND conn_id = ?`,
playerId,
c.conn.id,
);
if (rows.length === 0) return null;
const row = rows[0]!;
return {
matchId: row.match_id,
playerId: row.player_id,
teamId: row.team_id,
mode: row.mode as Mode,
connId: row.conn_id,
};
},
},
onDisconnect: async (c, conn) => {
await c.queue.send("unqueueForMatch", { connId: conn.id });
},
run: async (c) => {
for await (const message of c.queue.iter()) {
if (message.name === "queueForMatch") {
await processQueueEntry(
c,
message.body.mode,
message.body.playerId,
message.body.connId,
);
} else if (message.name === "unqueueForMatch") {
await c.db.execute(
`DELETE FROM player_pool WHERE conn_id = ?`,
message.body.connId,
);
await broadcastQueueSizes(c);
} else if (message.name === "matchCompleted") {
await c.db.execute(
`DELETE FROM matches WHERE match_id = ?`,
message.body.matchId,
);
}
}
},
});
async function broadcastQueueSizes(c: ActorContextOf<typeof arenaMatchmaker>) {
const rows = await c.db.execute<{ mode: string; cnt: number }>(
`SELECT mode, COUNT(*) as cnt FROM player_pool GROUP BY mode`,
);
const counts: Record<string, number> = {};
for (const row of rows) {
counts[row.mode] = row.cnt;
}
c.broadcast("queueUpdate", { counts });
}
async function processQueueEntry(
c: ActorContextOf<typeof arenaMatchmaker>,
mode: Mode,
playerId: string,
connId: string,
): Promise<void> {
const config = MODE_CONFIG[mode];
await c.db.execute(
`INSERT OR REPLACE INTO player_pool (player_id, mode, queued_at, conn_id) VALUES (?, ?, ?, ?)`,
playerId,
mode,
Date.now(),
connId,
);
await broadcastQueueSizes(c);
const countRows = await c.db.execute<{ cnt: number }>(
`SELECT COUNT(*) as cnt FROM player_pool WHERE mode = ?`,
mode,
);
const count = countRows[0]?.cnt ?? 0;
if (count >= config.capacity) {
await fillMatch(c, mode, config);
}
}
async function fillMatch(
c: ActorContextOf<typeof arenaMatchmaker>,
mode: Mode,
config: { capacity: number; teams: number },
) {
const queued = await c.db.execute<QueuePlayerRow>(
`SELECT player_id, conn_id FROM player_pool WHERE mode = ? ORDER BY queued_at ASC LIMIT ?`,
mode,
config.capacity,
);
const queuedPlayers = queued.map((r) => ({
playerId: r.player_id,
connId: r.conn_id,
}));
const playerIds = queuedPlayers.map((r) => r.playerId);
for (const pid of playerIds) {
await c.db.execute(`DELETE FROM player_pool WHERE player_id = ?`, pid);
}
const matchId = crypto.randomUUID();
const assignedPlayers = queuedPlayers.map((queuedPlayer, idx) => ({
playerId: queuedPlayer.playerId,
connId: queuedPlayer.connId,
teamId: config.teams > 0 ? idx % config.teams : -1,
}));
const client = c.client<typeof registry>();
await client.arenaMatch.create([matchId], {
input: {
matchId,
mode,
capacity: config.capacity,
assignedPlayers: assignedPlayers.map((ap) => ({
playerId: ap.playerId,
teamId: ap.teamId,
})),
},
});
await c.db.execute(
`INSERT INTO matches (match_id, mode, capacity, created_at) VALUES (?, ?, ?, ?)`,
matchId,
mode,
config.capacity,
Date.now(),
);
await broadcastQueueSizes(c);
for (const ap of assignedPlayers) {
await c.db.execute(
`INSERT INTO assignments (player_id, match_id, team_id, mode, conn_id) VALUES (?, ?, ?, ?, ?)`,
ap.playerId,
matchId,
ap.teamId,
mode,
ap.connId,
);
c.broadcast("assignmentReady", {
matchId,
playerId: ap.playerId,
teamId: ap.teamId,
mode,
connId: ap.connId,
} satisfies ArenaAssignment);
}
}
async function migrateTables(dbHandle: RawAccess) {
await dbHandle.execute(`
CREATE TABLE IF NOT EXISTS player_pool (
player_id TEXT PRIMARY KEY,
mode TEXT NOT NULL,
queued_at INTEGER NOT NULL,
conn_id TEXT
)
`);
await dbHandle.execute(`
CREATE TABLE IF NOT EXISTS matches (
match_id TEXT PRIMARY KEY,
mode TEXT NOT NULL,
capacity INTEGER NOT NULL,
created_at INTEGER NOT NULL
)
`);
await dbHandle.execute(`
CREATE TABLE IF NOT EXISTS assignments (
player_id TEXT PRIMARY KEY,
match_id TEXT NOT NULL,
team_id INTEGER NOT NULL,
mode TEXT NOT NULL,
conn_id TEXT
)
`);
}
queueForMatchis an action. It creates theplayerId, recordsc.conn.id, and puts a message on the internalqueueForMatchqueue. It returns right away. The match arrives later as an event, because nobody knows how long the wait will be.runhandles one queue message at a time, so two players joining at once can’t both fill the last slot. Each entry goes intoplayer_pool, the new counts go out asqueueUpdate, andfillMatchruns once the mode reaches itsMODE_CONFIGcapacity.fillMatchtakes the players who have waited longest and assigns teams by position (idx % teams, or-1for free-for-all). It creates thearenaMatchActor with the roster as input, saves each row inassignments, and broadcastsassignmentReady.onDisconnectsendsunqueueForMatch, so a player who closes the tab leaves the pool.
assignmentReady is broadcast to every client connected to the matchmaker, and the playerId in it is enough to connect as that player. In production, send each assignment only to its owner with conn.send(...) (the row already stores conn_id), and check a signed token in the match.
Create the match
Each match is its own arenaMatch Actor, keyed by the match id. It starts with the full roster and goes live once everyone has connected:
createState: (
_c,
input: {
matchId: string;
mode: Mode;
capacity: number;
assignedPlayers: AssignedPlayer[];
},
): State => {
const players: Record<string, PlayerEntry> = {};
for (const ap of input.assignedPlayers) {
players[ap.playerId] = {
connId: null,
teamId: ap.teamId,
color: getPlayerColor(ap.playerId),
x: Math.random() * WORLD_SIZE,
y: Math.random() * WORLD_SIZE,
lastPositionAt: Date.now(),
alive: true,
score: 0,
};
}
return {
matchId: input.matchId,
mode: input.mode,
capacity: input.capacity,
tick: 0,
phase: "waiting",
players,
winnerTeam: null,
winnerPlayerId: null,
};
},
onConnect: (c, conn) => {
const playerId = (conn.params as { playerId?: string })?.playerId;
if (!playerId) return;
const player = c.state.players[playerId];
if (!player) {
conn.disconnect("invalid_player");
return;
}
player.connId = conn.id;
if (c.state.phase === "waiting") {
const allConnected = Object.values(c.state.players).every(
(p) => p.connId !== null,
);
if (allConnected) {
c.state.phase = "live";
}
}
broadcastSnapshot(c);
},
createStateseeds every assigned player from the input with their team, color, and a random spawn point. 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 { interval } from "rivetkit/utils";
import type { registry } from "../index.ts";
import { getPlayerColor } from "../player-color.ts";
import {
MAX_SPEED,
type Mode,
SCORE_LIMIT,
SHOOT_ANGLE,
SHOOT_RANGE,
TICK_MS,
WORLD_SIZE,
} from "./config.ts";
interface PlayerEntry {
connId: string | null;
teamId: number;
color: string;
x: number;
y: number;
lastPositionAt: number;
alive: boolean;
score: number;
}
interface State {
matchId: string;
mode: Mode;
capacity: number;
tick: number;
phase: "waiting" | "live" | "finished";
players: Record<string, PlayerEntry>;
winnerTeam: number | null;
winnerPlayerId: string | null;
}
interface AssignedPlayer {
playerId: string;
teamId: number;
}
export const arenaMatch = actor({
options: { name: "Arena - Match", icon: "crosshairs" },
events: {
snapshot: event<Snapshot>(),
shoot: event<ShootEvent>(),
},
createState: (
_c,
input: {
matchId: string;
mode: Mode;
capacity: number;
assignedPlayers: AssignedPlayer[];
},
): State => {
const players: Record<string, PlayerEntry> = {};
for (const ap of input.assignedPlayers) {
players[ap.playerId] = {
connId: null,
teamId: ap.teamId,
color: getPlayerColor(ap.playerId),
x: Math.random() * WORLD_SIZE,
y: Math.random() * WORLD_SIZE,
lastPositionAt: Date.now(),
alive: true,
score: 0,
};
}
return {
matchId: input.matchId,
mode: input.mode,
capacity: input.capacity,
tick: 0,
phase: "waiting",
players,
winnerTeam: null,
winnerPlayerId: null,
};
},
onConnect: (c, conn) => {
const playerId = (conn.params as { playerId?: string })?.playerId;
if (!playerId) return;
const player = c.state.players[playerId];
if (!player) {
conn.disconnect("invalid_player");
return;
}
player.connId = conn.id;
if (c.state.phase === "waiting") {
const allConnected = Object.values(c.state.players).every(
(p) => p.connId !== null,
);
if (allConnected) {
c.state.phase = "live";
}
}
broadcastSnapshot(c);
},
onDestroy: async (c) => {
const client = c.client<typeof registry>();
await client.arenaMatchmaker
.getOrCreate(["main"])
.send("matchCompleted", { matchId: c.state.matchId });
},
onDisconnect: (c, conn) => {
const found = findPlayerByConnId(c.state, conn.id);
if (!found) return;
const [, player] = found;
player.connId = null;
broadcastSnapshot(c);
},
run: async (c) => {
const tick = interval(TICK_MS);
while (!c.aborted) {
await tick();
if (c.aborted) break;
if (c.state.phase !== "live") continue;
c.state.tick += 1;
checkWinCondition(c);
broadcastSnapshot(c);
}
},
actions: {
updatePosition: (c, input: { x: number; y: number }) => {
const found = findPlayerByConnId(c.state, c.conn.id);
if (!found) {
throw new UserError("player not found", {
code: "player_not_found",
});
}
const [, player] = found;
if (!player.alive) {
throw new UserError("player is not alive", {
code: "not_alive",
});
}
const now = Date.now();
const elapsed = Math.max(0, (now - player.lastPositionAt) / 1000);
const maxDist = MAX_SPEED * elapsed;
const dx = input.x - player.x;
const dy = input.y - player.y;
const dist = Math.sqrt(dx * dx + dy * dy);
let newX: number;
let newY: number;
if (dist > maxDist && dist > 0) {
newX = player.x + (dx / dist) * maxDist;
newY = player.y + (dy / dist) * maxDist;
} else {
newX = input.x;
newY = input.y;
}
player.x = Math.max(0, Math.min(WORLD_SIZE, newX));
player.y = Math.max(0, Math.min(WORLD_SIZE, newY));
player.lastPositionAt = now;
},
shoot: (c, input: { dirX: number; dirY: number }) => {
const found = findPlayerByConnId(c.state, c.conn.id);
if (!found) {
throw new UserError("player not found", {
code: "player_not_found",
});
}
const [shooterId, shooter] = found;
if (!shooter.alive) {
throw new UserError("player is not alive", {
code: "not_alive",
});
}
if (c.state.phase !== "live") {
throw new UserError("match is not live", { code: "not_live" });
}
const mag = Math.sqrt(
input.dirX * input.dirX + input.dirY * input.dirY,
);
if (mag === 0) return;
const ndx = input.dirX / mag;
const ndy = input.dirY / mag;
let closestId: string | null = null;
let closestDist = Infinity;
for (const [targetId, target] of Object.entries(c.state.players)) {
if (targetId === shooterId) continue;
if (!target.alive) continue;
if (shooter.teamId >= 0 && target.teamId === shooter.teamId)
continue;
const tx = target.x - shooter.x;
const ty = target.y - shooter.y;
const targetDist = Math.sqrt(tx * tx + ty * ty);
if (targetDist > SHOOT_RANGE || targetDist === 0) continue;
const dot = (tx / targetDist) * ndx + (ty / targetDist) * ndy;
const angle = Math.acos(Math.max(-1, Math.min(1, dot)));
if (angle > SHOOT_ANGLE) continue;
if (targetDist < closestDist) {
closestDist = targetDist;
closestId = targetId;
}
}
if (closestId) {
const victim = c.state.players[closestId]!;
shooter.score += 1;
victim.x = Math.random() * WORLD_SIZE;
victim.y = Math.random() * WORLD_SIZE;
victim.lastPositionAt = Date.now();
}
const shootEvent: ShootEvent = {
shooterId,
fromX: shooter.x,
fromY: shooter.y,
dirX: ndx,
dirY: ndy,
hitPlayerId: closestId,
};
c.broadcast("shoot", shootEvent);
checkWinCondition(c);
if ((c.state.phase as string) === "finished") {
broadcastSnapshot(c);
}
},
getSnapshot: (c) => buildSnapshot(c),
},
});
function checkWinCondition(c: ActorContextOf<typeof arenaMatch>) {
if (c.state.phase !== "live") return;
if (c.state.mode === "ffa") {
for (const [id, player] of Object.entries(c.state.players)) {
if (player.score >= SCORE_LIMIT) {
c.state.phase = "finished";
c.state.winnerPlayerId = id;
return;
}
}
} else {
const teamScores: Record<number, number> = {};
for (const player of Object.values(c.state.players)) {
teamScores[player.teamId] =
(teamScores[player.teamId] ?? 0) + player.score;
}
for (const [teamId, score] of Object.entries(teamScores)) {
if (score >= SCORE_LIMIT) {
c.state.phase = "finished";
c.state.winnerTeam = Number(teamId);
return;
}
}
}
}
function broadcastSnapshot(c: ActorContextOf<typeof arenaMatch>) {
c.broadcast("snapshot", buildSnapshot(c));
}
interface Snapshot {
matchId: string;
mode: Mode;
capacity: number;
tick: number;
phase: "waiting" | "live" | "finished";
winnerTeam: number | null;
winnerPlayerId: string | null;
worldSize: number;
scoreLimit: number;
players: Record<
string,
{ x: number; y: number; teamId: number; color: string; score: number }
>;
}
interface ShootEvent {
shooterId: string;
fromX: number;
fromY: number;
dirX: number;
dirY: number;
hitPlayerId: string | null;
}
function buildSnapshot(c: ActorContextOf<typeof arenaMatch>): Snapshot {
const players: Record<
string,
{ x: number; y: number; teamId: number; color: string; score: number }
> = {};
for (const [id, entry] of Object.entries(c.state.players)) {
players[id] = {
x: entry.x,
y: entry.y,
teamId: entry.teamId,
color: entry.color,
score: entry.score,
};
}
return {
matchId: c.state.matchId,
mode: c.state.mode,
capacity: c.state.capacity,
tick: c.state.tick,
phase: c.state.phase,
winnerTeam: c.state.winnerTeam,
winnerPlayerId: c.state.winnerPlayerId,
worldSize: WORLD_SIZE,
scoreLimit: SCORE_LIMIT,
players,
};
}
function findPlayerByConnId(
state: State,
connId: string,
): [string, PlayerEntry] | null {
for (const [id, entry] of Object.entries(state.players)) {
if (entry.connId === connId) return [id, entry];
}
return null;
}
interval(TICK_MS)ticks every 50 ms, twice as often as the battle royale, because arena fights are short and close. Ticks are skipped until the match islive. Each live tick checks the win condition and broadcasts onesnapshot.- 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,onDestroysendsmatchCompletedso 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.
- Queues: how the matchmaker handles one request at a time.
- Events: broadcast to everyone or send to one connection.