Skip to main content
Games

Ranked Matchmaking

Build a ranked queue that pairs players by rating, runs each duel in its own match Actor, and updates ratings and a leaderboard when it ends.

See the full example on GitHub. View on GitHub
Playerbrowser clientrankedMatchmakerrating queuerankedMatch20 ticks per secondrankedPlayerrating, wins, lossesrankedLeaderboardtop 20 in SQLitequeueForMatch1getRating2assignmentReady3updatePosition, shoot4snapshot5matchCompleted6applyMatchResult7updatePlayer8One per match or usernameRivet ActorCallReply

What we’ll build:

  • A rankedPlayer Actor per username that stores the player’s rating, wins, and losses in SQLite.
  • A rankedMatchmaker Actor that pairs players by rating and widens each player’s search window the longer they wait.
  • A rankedMatch Actor per duel that runs a 20 tick per second loop, checks movement, and resolves shots.
  • Elo rating updates and a rankedLeaderboard Actor that keeps the top 20 players.

Install

npm add rivetkit

Create the matchmaker

One rankedMatchmaker Actor, keyed ["main"], owns the queue. It stores waiting players in a player_pool table in its SQLite database and pairs them by rating:

/*
This matchmaker uses a rating window flow.
1. queueForMatch adds players to player_pool with current rating and queue time, then tries to pair them.
2. attemptPairing looks for two players whose rating windows overlap and removes both from the pool in one transaction.
3. createRankedMatch creates a match actor for the pair, stores assignments, and broadcasts assignmentReady.
4. A background loop retries pairing every few seconds, because rating windows widen while players wait.
5. matchCompleted updates player rating actors and leaderboard entries, then removes the match row.
*/
import { type ActorContextOf, actor, event } from "rivetkit";
import { db, type RawAccess } from "rivetkit/db";

import type { registry } from "../index.ts";
import {
	INITIAL_RATING_WINDOW,
	MAX_RATING_WINDOW,
	WINDOW_EXPAND_PER_SEC,
} from "./config.ts";

export interface RankedAssignment {
	matchId: string;
	username: string;
	rating: number;
	connId: string | null;
}

export type RankedQueueUpdate =
	| {
			queued: false;
			count: number;
	  }
	| {
			queued: true;
			count: number;
			username: string;
			rating: number;
			queueDurationMs: number;
			ratingWindow: number;
			ratingMin: number;
			ratingMax: number;
	  };

type QueuePlayerRow = {
	username: string;
	rating: number;
	queued_at: number;
	conn_id: string | null;
};

interface MatchmakerVars {
	// `onWake` stores its loops here so `onSleep` can wait for them.
	pairingRetryTicker: Promise<void> | null;
	queueUpdateTicker: Promise<void> | null;
}

const QUEUE_UPDATE_TICK_MS = 1000;
const PAIRING_RETRY_TICK_MS = 2000;

export const rankedMatchmaker = actor({
	options: { name: "Ranked - Matchmaker", icon: "ranking-star" },
	db: db({
		onMigrate: migrateTables,
	}),
	events: {
		assignmentReady: event<RankedAssignment>(),
		queueUpdate: event<RankedQueueUpdate>(),
	},
	createVars: (): MatchmakerVars => ({
		pairingRetryTicker: null,
		queueUpdateTicker: null,
	}),
	// `onWake` runs on every start, so both loops restart after a sleep or crash.
	// They run separately, because a pairing attempt waits for a match actor to
	// be created and must not hold up the queue updates.
	onWake: (c) => {
		c.vars.pairingRetryTicker = runPairingRetryTicker(c).catch((error) => {
			c.log.error({ msg: "pairing retry loop failed", error });
		});
		c.vars.queueUpdateTicker = runQueueUpdateTicker(c).catch((error) => {
			c.log.error({ msg: "queue update loop failed", error });
		});
	},
	// `c.abortSignal` fires on shutdown, so both loops exit on their own
	onSleep: async (c) => {
		await Promise.all([
			c.vars.pairingRetryTicker,
			c.vars.queueUpdateTicker,
		]);
	},
	actions: {
		queueForMatch: async (c, { username }: { username: string }) => {
			const connId = c.conn.id;

			const client = c.client<typeof registry>();
			const playerHandle = client.rankedPlayer.getOrCreate([username]);
			await playerHandle.initialize({ username });
			const rating = await playerHandle.getRating();

			// Clear any stale assignment for this username before re-queueing.
			await c.db.execute(
				`DELETE FROM assignments WHERE username = ?`,
				username,
			);

			await c.db.execute(
				`INSERT OR REPLACE INTO player_pool (username, rating, queued_at, conn_id) VALUES (?, ?, ?, ?)`,
				username,
				rating,
				Date.now(),
				connId,
			);

			await sendQueueUpdates(c);
			await attemptPairing(c);
			return { queued: true, connId };
		},
		getQueueSize: async (c) => {
			const rows = await c.db.execute<{ cnt: number }>(
				`SELECT COUNT(*) as cnt FROM player_pool`,
			);
			return rows[0]?.cnt ?? 0;
		},
		getAssignment: async (c, { username }: { username: string }) => {
			const rows = await c.db.execute<{
				match_id: string;
				username: string;
				rating: number;
				conn_id: string | null;
			}>(
				`SELECT * FROM assignments WHERE username = ? AND conn_id = ?`,
				username,
				c.conn.id,
			);
			if (rows.length === 0) return null;
			const row = rows[0]!;
			return {
				matchId: row.match_id,
				username: row.username,
				rating: row.rating,
				connId: row.conn_id,
			};
		},
		// Every step is safe to repeat: each player applies a match id once,
		// the leaderboard keeps the profile with the most games, and the
		// deletes do nothing the second time. A match can therefore send a
		// report again after one fails partway.
		matchCompleted: async (
			c,
			body: {
				matchId: string;
				winnerUsername: string;
				loserUsername: string;
				winnerNewRating: number;
				loserNewRating: number;
			},
		) => {
			const client = c.client<typeof registry>();

			if (body.winnerUsername && body.loserUsername) {
				const winnerHandle = client.rankedPlayer.getOrCreate([
					body.winnerUsername,
				]);
				await winnerHandle.applyMatchResult({
					matchId: body.matchId,
					won: true,
					newRating: body.winnerNewRating,
				});
				const loserHandle = client.rankedPlayer.getOrCreate([
					body.loserUsername,
				]);
				await loserHandle.applyMatchResult({
					matchId: body.matchId,
					won: false,
					newRating: body.loserNewRating,
				});

				const winnerProfile = await winnerHandle.getProfile();
				const loserProfile = await loserHandle.getProfile();

				const lb = client.rankedLeaderboard.getOrCreate(["main"]);
				await lb.updatePlayer(winnerProfile);
				await lb.updatePlayer(loserProfile);
			}

			await c.db.execute(
				`DELETE FROM matches WHERE match_id = ?`,
				body.matchId,
			);
			await c.db.execute(
				`DELETE FROM assignments WHERE match_id = ?`,
				body.matchId,
			);
		},
	},
	onDisconnect: async (c, conn) => {
		await c.db.execute(
			`DELETE FROM player_pool WHERE conn_id = ?`,
			conn.id,
		);
		await sendQueueUpdates(c);
	},
});

/** Retries pairing on a timer, because rating windows widen while players wait. */
async function runPairingRetryTicker(
	c: ActorContextOf<typeof rankedMatchmaker>,
): Promise<void> {
	while (!c.aborted) {
		await delay(PAIRING_RETRY_TICK_MS, c.abortSignal);
		if (c.aborted) break;

		try {
			await attemptPairing(c);
		} catch (error) {
			// The pair is back in the pool, so the next tick tries again.
			c.log.warn({ msg: "pairing retry failed", error });
		}
	}
}

async function attemptPairing(c: ActorContextOf<typeof rankedMatchmaker>) {
	// queueForMatch and the retry loop can pair at the same time, so pick the
	// pair and remove both players in one transaction. A player can't be
	// claimed by two matches.
	const pair = await c.db.transaction(
		async (tx) => {
			const pool = await tx.execute<QueuePlayerRow>(
				`SELECT * FROM player_pool ORDER BY queued_at ASC`,
			);
			const found = findPair(pool, Date.now());
			if (!found) return null;

			const [a, b] = found;
			await tx.execute(
				`DELETE FROM player_pool WHERE username IN (?, ?)`,
				a.username,
				b.username,
			);
			return found;
		},
		{ name: "claim-pair" },
	);
	if (!pair) return;

	const [a, b] = pair;
	await createRankedMatch(c, a, b);
}

/**
 * Returns the longest-waiting player and their closest opponent whose rating
 * windows both fit the difference, or null if no two players fit.
 */
function findPair(
	pool: QueuePlayerRow[],
	now: number,
): [QueuePlayerRow, QueuePlayerRow] | null {
	if (pool.length < 2) return null;

	for (let i = 0; i < pool.length; i++) {
		const a = pool[i]!;
		const aWaitSec = (now - a.queued_at) / 1000;
		const aWindow = Math.min(
			INITIAL_RATING_WINDOW + WINDOW_EXPAND_PER_SEC * aWaitSec,
			MAX_RATING_WINDOW,
		);

		let bestIdx = -1;
		let bestDiff = Infinity;

		for (let j = i + 1; j < pool.length; j++) {
			const b = pool[j]!;
			const bWaitSec = (now - b.queued_at) / 1000;
			const bWindow = Math.min(
				INITIAL_RATING_WINDOW + WINDOW_EXPAND_PER_SEC * bWaitSec,
				MAX_RATING_WINDOW,
			);

			const diff = Math.abs(a.rating - b.rating);
			if (diff <= aWindow && diff <= bWindow && diff < bestDiff) {
				bestDiff = diff;
				bestIdx = j;
			}
		}

		if (bestIdx >= 0) {
			return [a, pool[bestIdx]!];
		}
	}
	return null;
}

async function createRankedMatch(
	c: ActorContextOf<typeof rankedMatchmaker>,
	a: QueuePlayerRow,
	b: QueuePlayerRow,
) {
	const matchId = crypto.randomUUID();
	const assignedPlayers = [
		{
			username: a.username,
			rating: a.rating,
			connId: a.conn_id,
		},
		{
			username: b.username,
			rating: b.rating,
			connId: b.conn_id,
		},
	] as const;

	// Create the match outside the claim transaction, because a transaction
	// must not wait on another actor.
	const client = c.client<typeof registry>();
	try {
		await client.rankedMatch.create([matchId], {
			input: {
				assignedPlayers: assignedPlayers.map((p) => ({
					username: p.username,
					rating: p.rating,
				})),
			},
		});
	} catch (error) {
		await returnToPool(c, [a, b]);
		throw error;
	}

	await c.db.transaction(
		async (tx) => {
			await tx.execute(
				`INSERT INTO matches (match_id, created_at) VALUES (?, ?)`,
				matchId,
				Date.now(),
			);
			for (const player of assignedPlayers) {
				await tx.execute(
					`INSERT OR REPLACE INTO assignments (username, match_id, rating, conn_id) VALUES (?, ?, ?, ?)`,
					player.username,
					matchId,
					player.rating,
					player.connId,
				);
			}
		},
		{ name: "save-match" },
	);

	await sendQueueUpdates(c);

	for (const player of assignedPlayers) {
		c.broadcast("assignmentReady", {
			matchId,
			username: player.username,
			rating: player.rating,
			connId: player.connId,
		} satisfies RankedAssignment);
	}
}

/** Puts claimed players back at their original place in the pool. */
async function returnToPool(
	c: ActorContextOf<typeof rankedMatchmaker>,
	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) {
				// A player who queued again in the meantime keeps the newer entry.
				await tx.execute(
					`INSERT OR IGNORE INTO player_pool (username, rating, queued_at, conn_id) VALUES (?, ?, ?, ?)`,
					player.username,
					player.rating,
					player.queued_at,
					player.conn_id,
				);
			}
		},
		{ name: "return-players" },
	);
	await sendQueueUpdates(c);
}

function calculateRatingWindow(now: number, queuedAt: number): number {
	const waitSec = Math.max(0, (now - queuedAt) / 1000);
	return Math.min(
		INITIAL_RATING_WINDOW + WINDOW_EXPAND_PER_SEC * waitSec,
		MAX_RATING_WINDOW,
	);
}

async function sendQueueUpdates(c: ActorContextOf<typeof rankedMatchmaker>) {
	if (c.conns.size === 0) return;

	const now = Date.now();
	const pool = await c.db.execute<QueuePlayerRow>(
		`SELECT * FROM player_pool`,
	);
	const count = pool.length;

	const playerByConnId = new Map<string, QueuePlayerRow>();
	for (const row of pool) {
		if (!row.conn_id) continue;
		const existing = playerByConnId.get(row.conn_id);
		if (!existing || row.queued_at > existing.queued_at) {
			playerByConnId.set(row.conn_id, row);
		}
	}

	for (const [connId, conn] of c.conns.entries()) {
		const player = playerByConnId.get(connId);
		if (!player) {
			conn.send("queueUpdate", {
				queued: false,
				count,
			});
			continue;
		}

		const ratingWindow = calculateRatingWindow(now, player.queued_at);
		conn.send("queueUpdate", {
			queued: true,
			count,
			username: player.username,
			rating: player.rating,
			queueDurationMs: Math.max(0, now - player.queued_at),
			ratingWindow: Math.round(ratingWindow),
			ratingMin: Math.round(player.rating - ratingWindow),
			ratingMax: Math.round(player.rating + ratingWindow),
		});
	}
}

async function runQueueUpdateTicker(
	c: ActorContextOf<typeof rankedMatchmaker>,
): Promise<void> {
	while (!c.aborted) {
		await delay(QUEUE_UPDATE_TICK_MS, c.abortSignal);
		if (c.aborted) break;
		await sendQueueUpdates(c);
	}
}

/** Resolves after `ms`, or as soon as the actor starts shutting down. */
function delay(ms: number, signal: AbortSignal): Promise<void> {
	return new Promise((resolve) => {
		if (signal.aborted) {
			resolve();
			return;
		}

		const finish = () => {
			clearTimeout(timer);
			signal.removeEventListener("abort", finish);
			resolve();
		};
		const timer = setTimeout(finish, ms);
		signal.addEventListener("abort", finish, { once: true });
	});
}

async function migrateTables(dbHandle: RawAccess) {
	await dbHandle.execute(`
		CREATE TABLE IF NOT EXISTS player_pool (
			username TEXT PRIMARY KEY,
			rating INTEGER NOT NULL,
			queued_at INTEGER NOT NULL,
			conn_id TEXT
		)
	`);
	await dbHandle.execute(`
		CREATE TABLE IF NOT EXISTS matches (
			match_id TEXT PRIMARY KEY,
			created_at INTEGER NOT NULL
		)
	`);
	await dbHandle.execute(`
		CREATE TABLE IF NOT EXISTS assignments (
			username TEXT PRIMARY KEY,
			match_id TEXT NOT NULL,
			rating INTEGER NOT NULL,
			conn_id TEXT
		)
	`);
}
  • The client only sends a username. The matchmaker reads the rating itself with getRating on that player’s rankedPlayer Actor, so a client cannot queue with a made-up rating.
  • Each player’s window starts at 50 rating points and grows by 25 per second of waiting, up to 500. Two players match only if their difference fits inside both windows. The player who has waited longest is paired first, with the closest opponent that fits.
  • queueForMatch adds the player to player_pool and calls attemptPairing right away. The windows keep growing while nobody new joins, so onWake also starts runPairingRetryTicker, which calls attemptPairing every 2 seconds.
  • Actions and the retry loop run at the same time, so attemptPairing picks a pair and removes both players from player_pool in one c.db.transaction. Two pairings at once can’t claim the same player.
  • createRankedMatch creates the rankedMatch Actor after the transaction commits, because a transaction must not wait on another Actor. If creating it fails, returnToPool puts the players who are still connected back in their old places.
  • onDisconnect removes a player who closes the tab.

onWake starts a second loop, runQueueUpdateTicker, that sends every connection its own queueUpdate once a second. A queued player gets their wait time and current rating range. It uses conn.send(...) instead of c.broadcast(...) because each player gets different numbers. The two loops run separately, so a slow match creation never holds up the queue updates. Both wait with c.abortSignal, so they exit as soon as the Actor starts to sleep, and onSleep waits for them. Waiting players stay connected, which keeps the matchmaker awake.

Create the match

When two players pair, the matchmaker creates a rankedMatch Actor with both usernames and ratings as input, then broadcasts assignmentReady. The match is a short realtime duel, first to 5 hits:

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 {
	K_FACTOR,
	MAX_SPEED,
	SCORE_LIMIT,
	SHOOT_ANGLE,
	SHOOT_RANGE,
	SNAPSHOT_INTERVAL_MS,
	TICK_MS,
	WORLD_SIZE,
} from "./config.ts";

// How long to wait before sending a failed match report again
const REPORT_RETRY_MS = 1_000;

interface PlayerEntry {
	connId: string | null;
	color: string;
	x: number;
	y: number;
	lastPositionAt: number;
	alive: boolean;
	score: number;
	rating: number;
}

interface MatchVars {
	matchId: string;
	tick: number;
	phase: "waiting" | "live" | "finished";
	players: Record<string, PlayerEntry>;
	winnerId: string | null;
	resultReported: boolean;
	// A failed report armed a retry schedule, so the loop stops sending it.
	reportRetryScheduled: boolean;
	// `onWake` stores its loop here so `onSleep` can wait for it.
	loop: Promise<void> | null;
}

interface AssignedPlayer {
	username: string;
	rating: number;
}

type MatchInfoRow = {
	tick: number;
	phase: MatchVars["phase"];
	winner_id: string | null;
	result_reported: number;
};

type RosterRow = {
	username: string;
	rating: number;
	x: number;
	y: number;
	score: number;
};

export const rankedMatch = actor({
	options: { name: "Ranked - Match", icon: "ranking-star" },
	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 DEFAULT 0,
					phase TEXT NOT NULL DEFAULT 'waiting',
					winner_id TEXT,
					result_reported INTEGER NOT NULL DEFAULT 0
				)
			`);
			await db.execute(`
				CREATE TABLE IF NOT EXISTS roster (
					username TEXT PRIMARY KEY,
					rating INTEGER NOT NULL,
					x REAL NOT NULL,
					y REAL NOT NULL,
					score INTEGER NOT NULL DEFAULT 0
				)
			`);
		},
	}),
	onCreate: async (
		c,
		input: { assignedPlayers: AssignedPlayer[] },
	) => {
		// Save the two players, their ratings, and a random spawn point once,
		// so every start of this match sees the same roster.
		await c.db.transaction(
			async (tx) => {
				await tx.execute("INSERT INTO match_info (id) VALUES (1)");
				for (const ap of input.assignedPlayers) {
					await tx.execute(
						"INSERT INTO roster (username, rating, x, y) VALUES (?, ?, ?, ?)",
						ap.username,
						ap.rating,
						Math.random() * WORLD_SIZE,
						Math.random() * WORLD_SIZE,
					);
				}
			},
			{ name: "create-match" },
		);
	},
	createVars: async (c): Promise<MatchVars> => {
		const [info] = await c.db.execute<MatchInfoRow>(
			"SELECT tick, phase, winner_id, result_reported FROM match_info WHERE id = 1",
		);
		const roster = await c.db.execute<RosterRow>(
			"SELECT username, rating, x, y, score FROM roster",
		);

		// The live duel runs in memory and starts from the last save. No
		// connection survives a restart, so both players start unbound.
		const players: Record<string, PlayerEntry> = {};
		for (const row of roster) {
			players[row.username] = {
				connId: null,
				color: getPlayerColor(row.username),
				x: row.x,
				y: row.y,
				lastPositionAt: Date.now(),
				alive: true,
				score: row.score,
				rating: row.rating,
			};
		}
		return {
			matchId: c.key[0]!,
			tick: info!.tick,
			phase: info!.phase,
			players,
			winnerId: info!.winner_id,
			resultReported: info!.result_reported === 1,
			reportRetryScheduled: false,
			loop: null,
		};
	},
	onConnect: (c, conn) => {
		const params = conn.params as { username?: string; playerId?: string };
		const playerId = params.username ?? params.playerId;
		if (!playerId) {
			conn.disconnect("invalid_player");
			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. Wait for the loop to exit, so a
		// result it is still reporting is not reported a second time here.
		await c.vars.loop;
		await reportMatchCompletedIfNeeded(c, { allowNoResult: true });
	},
	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: {
		// Runs from the schedule that `reportResult` arms after a failed report.
		retryReport: async (c) => {
			if (!c.scheduledFire) {
				throw new UserError("retryReport only runs from a schedule", {
					code: "schedule_only",
				});
			}
			c.vars.reportRetryScheduled = false;
			await reportResult(c);
		},
		updatePosition: (c, input: { x: number; y: number }) => {
			const found = findPlayerByConnId(c.vars, c.conn.id);
			if (!found) {
				return;
			}
			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) {
				return;
			}
			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;

				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 rankedMatch>) {
	if (c.vars.phase !== "live") return;
	for (const [id, player] of Object.entries(c.vars.players)) {
		if (player.score >= SCORE_LIMIT) {
			c.vars.phase = "finished";
			c.vars.winnerId = id;
			return;
		}
	}
}

/** Sends the match result to the matchmaker once. Returns true if this call sent it. */
async function reportMatchCompletedIfNeeded(
	c: ActorContextOf<typeof rankedMatch>,
	options?: { allowNoResult?: boolean },
): Promise<boolean> {
	const allowNoResult = options?.allowNoResult ?? false;

	if (c.vars.resultReported) return false;
	if (c.vars.phase !== "finished" && !allowNoResult) return false;

	const client = c.client<typeof registry>();
	const entries = Object.entries(c.vars.players);
	if (c.vars.winnerId && entries.length === 2) {
		const loserUsername = entries.find(
			([id]) => id !== c.vars.winnerId,
		)?.[0];
		const winner = c.vars.players[c.vars.winnerId];
		const loser = loserUsername
			? c.vars.players[loserUsername]
			: undefined;
		if (winner && loser && loserUsername) {
			const [newWR, newLR] = calculateElo(winner.rating, loser.rating);
			await client.rankedMatchmaker.getOrCreate(["main"]).matchCompleted({
				matchId: c.vars.matchId,
				winnerUsername: c.vars.winnerId,
				loserUsername,
				winnerNewRating: newWR,
				loserNewRating: newLR,
			});
			c.vars.resultReported = true;
			return true;
		}
	}
	if (!allowNoResult) return false;

	await client.rankedMatchmaker.getOrCreate(["main"]).matchCompleted({
		matchId: c.vars.matchId,
		winnerUsername: "",
		loserUsername: "",
		winnerNewRating: 0,
		loserNewRating: 0,
	});
	c.vars.resultReported = true;
	return true;
}

function calculateElo(
	winnerRating: number,
	loserRating: number,
): [number, number] {
	const expectedW = 1 / (1 + 10 ** ((loserRating - winnerRating) / 400));
	const expectedL = 1 - expectedW;
	return [
		Math.round(winnerRating + K_FACTOR * (1 - expectedW)),
		Math.round(loserRating + K_FACTOR * (0 - expectedL)),
	];
}

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

/** Steps the duel every `TICK_MS` until the actor starts shutting down. */
async function runMatch(c: ActorContextOf<typeof rankedMatch>): 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") {
			if (!c.vars.reportRetryScheduled) await reportResult(c);
			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);
}

/**
 * Reports a finished match and saves that it did, so a restart does not send
 * the report again. The report is safe to send again, so a failure arms a
 * retry schedule instead of stopping the loop. A schedule wakes the match
 * even after both players have left and it went to sleep.
 */
async function reportResult(c: ActorContextOf<typeof rankedMatch>) {
	try {
		if (await reportMatchCompletedIfNeeded(c)) await saveMatch(c);
	} catch (error) {
		c.log.warn({ msg: "reporting the match result failed; retrying", error });
		c.vars.reportRetryScheduled = true;
		await c.schedule.after(REPORT_RETRY_MS, "retryReport");
	}
}

/**
 * Writes the phase, tick, winner, whether the result was reported, and both
 * players' positions and scores 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 rankedMatch>) {
	// Copy the values first, because actions such as updatePosition can run
	// while the transaction waits on SQLite.
	const { tick, phase, winnerId, resultReported } = c.vars;
	const players = Object.entries(c.vars.players).map(
		([username, player]) => ({
			username,
			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_id = ?, result_reported = ? WHERE id = 1",
				tick,
				phase,
				winnerId,
				resultReported ? 1 : 0,
			);
			for (const player of players) {
				await tx.execute(
					"UPDATE roster SET x = ?, y = ?, score = ? WHERE username = ?",
					player.x,
					player.y,
					player.score,
					player.username,
				);
			}
		},
		{ name: "save-match" },
	);
}

interface Snapshot {
	matchId: string;
	tick: number;
	phase: "waiting" | "live" | "finished";
	winnerId: string | null;
	worldSize: number;
	scoreLimit: number;
	players: Record<
		string,
		{ x: number; y: number; color: string; score: number; rating: number }
	>;
}

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

function buildSnapshot(c: ActorContextOf<typeof rankedMatch>): 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,
			score: entry.score,
			rating: entry.rating,
		};
	}
	return {
		matchId: c.vars.matchId,
		tick: c.vars.tick,
		phase: c.vars.phase,
		winnerId: c.vars.winnerId,
		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;
}
  • onCreate saves both usernames, ratings, and random spawn points to the match’s SQLite database once. createVars loads them into c.vars on every start, with the phase, tick, positions, scores, and resultReported from the last save. Both players start without a connection, because none survives a restart.
  • The match starts in waiting and switches to live in onConnect once both players are connected. A connection whose username param is not one of the two assigned players is dropped with invalid_player.
  • onWake starts runMatch as a background task on every start, and onSleep waits for it to save and exit. It ticks every 50 ms (TICK_MS). Each tick checks the win condition and broadcasts a snapshot. Connected players keep the match awake. Once both leave, it sleeps, and the next connection resumes it from the last save.
  • Ticks and actions only change memory. Every SNAPSHOT_INTERVAL_MS (1 second) in every phase, and once more when the Actor stops, saveMatch writes the match and both players in one c.db.transaction, so a crash loses at most about a second of play.
  • The client owns its own movement and sends positions, so movement feels instant. updatePosition never trusts them: it caps the distance by MAX_SPEED times the time since the last update and clamps to the world bounds. A teleport turns into a normal step.
  • Combat is decided on the server. shoot takes only a direction and checks range and angle against the server’s positions, so a client cannot claim a hit.

This is hybrid netcode: the client predicts its own movement and the server validates it, while hits and scores are server-authoritative. The duel only needs distance and angle checks. If your game needs real collisions, run a physics engine such as @dimforge/rapier2d inside the match and keep the client’s copy for prediction only.

Update ratings and the leaderboard

When someone reaches the score limit, the match computes both new ratings and reports them to the matchmaker:

/** Sends the match result to the matchmaker once. Returns true if this call sent it. */
async function reportMatchCompletedIfNeeded(
	c: ActorContextOf<typeof rankedMatch>,
	options?: { allowNoResult?: boolean },
): Promise<boolean> {
	const allowNoResult = options?.allowNoResult ?? false;

	if (c.vars.resultReported) return false;
	if (c.vars.phase !== "finished" && !allowNoResult) return false;

	const client = c.client<typeof registry>();
	const entries = Object.entries(c.vars.players);
	if (c.vars.winnerId && entries.length === 2) {
		const loserUsername = entries.find(
			([id]) => id !== c.vars.winnerId,
		)?.[0];
		const winner = c.vars.players[c.vars.winnerId];
		const loser = loserUsername
			? c.vars.players[loserUsername]
			: undefined;
		if (winner && loser && loserUsername) {
			const [newWR, newLR] = calculateElo(winner.rating, loser.rating);
			await client.rankedMatchmaker.getOrCreate(["main"]).matchCompleted({
				matchId: c.vars.matchId,
				winnerUsername: c.vars.winnerId,
				loserUsername,
				winnerNewRating: newWR,
				loserNewRating: newLR,
			});
			c.vars.resultReported = true;
			return true;
		}
	}
	if (!allowNoResult) return false;

	await client.rankedMatchmaker.getOrCreate(["main"]).matchCompleted({
		matchId: c.vars.matchId,
		winnerUsername: "",
		loserUsername: "",
		winnerNewRating: 0,
		loserNewRating: 0,
	});
	c.vars.resultReported = true;
	return true;
}

function calculateElo(
	winnerRating: number,
	loserRating: number,
): [number, number] {
	const expectedW = 1 / (1 + 10 ** ((loserRating - winnerRating) / 400));
	const expectedL = 1 - expectedW;
	return [
		Math.round(winnerRating + K_FACTOR * (1 - expectedW)),
		Math.round(loserRating + K_FACTOR * (0 - expectedL)),
	];
}
  • calculateElo uses a K-factor of 32 with the ratings the players had when they were paired.
  • matchCompleted is an action on the matchmaker, so the call returns once both players and the leaderboard have the result.
  • resultReported keeps the match from sending the result again once it went through, even though both the tick loop and onDestroy check it. onDestroy waits for the loop to exit first, so a report still in flight is not sent twice, and the tick loop saves right after reporting instead of waiting for the next periodic save.
  • Reporting is safe to repeat, so a report that fails, even partway through, arms a retryReport schedule after REPORT_RETRY_MS instead of stopping the loop. A schedule wakes the match even after both players have left and it went to sleep. Each rankedPlayer records the match id in the same transaction as the result and ignores a match it has already applied.
  • If the match is destroyed without a winner, it calls matchCompleted with empty usernames so the matchmaker still deletes the match’s rows.

The matchmaker then writes the result to each player and to the leaderboard:

  • rankedPlayer keeps the rating, wins, and losses in a one-row profile table in its SQLite database, keyed by username, so it outlives every match. applyMatchResult records the match id in applied_matches and, only if it is new, sets the new rating and adds the win or loss in one apply-match-result transaction. Two different results at once are both counted, and the same result sent twice counts once. It then broadcasts stateUpdate to anyone watching that profile.
  • rankedLeaderboard is one Actor keyed ["main"]. updatePlayer upserts the player’s row into the leaderboard table in one statement, then broadcasts the top 20 as leaderboardUpdate.
  • Two matches for the same player can finish at once and report out of order. Every result adds one win or loss, so the upsert only replaces a row with a profile that has at least as many games played. An older profile never overwrites a newer one.
  • The leaderboard copies rating, wins, and losses from getProfile. The rankedPlayer Actor stays the source of truth.

Usernames are not authenticated in this example, so anyone can queue as any username and play on its rating. In production, key rankedPlayer by a user id from your sign-in and check it on connect with authentication.

Register the Actors

Add the four ranked Actors to the registry and start it:

The example registers every game type in one registry. Ranked play needs rankedMatchmaker, rankedMatch, rankedPlayer, and rankedLeaderboard. Each Actor file imports type { registry } so that c.client<typeof registry>() is typed when one Actor calls another.

Connect players

The menu queues through the matchmaker and waits for an assignment. The game then connects to the match:

  • queueForMatch returns the connection id. waitForAssignment ignores any assignmentReady for another connection, so two tabs with the same username do not both take the match. It also calls getAssignment once, in case the event fired before the listener was attached. That happens whenever queueForMatch itself completes the pair.
  • The game connects with params: { username }, which is how onConnect finds the player.
  • The local player moves every frame and sends updatePosition every 50 ms. When a snapshot puts the player more than 40 pixels away, the client snaps to the server’s position. Other players are drawn with interpolation toward their last snapshot position.
  • The game is drawn on a canvas with requestAnimationFrame. React state is only used for menus.

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 and pick Ranked. Click Queue for Match, then Add Bot to queue a bot, or queue from a second tab with another username. While you wait, the queue panel shows your match window, such as match window: ±75 (1125 - 1275), growing every second. Move with WASD and click to shoot. After someone reaches 5 hits, click Leave. The menu’s leaderboard and your profile show the new ratings.

Next steps

  • Turn-Based Game: invite codes and a match that waits between moves.
  • Idle Game: a per-player Actor with a shared leaderboard.
  • Transactions: how the matchmaker claims a pair when requests overlap.
Edit this page Last updated November 10, 2026