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.
  • 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.
2. attemptPairing scans the pool and looks for two players whose rating windows overlap.
3. createRankedMatch removes paired players, creates a match actor, stores assignments, and broadcasts assignmentReady.
4. matchCompleted updates player rating actors and leaderboard entries, then removes the match row.
*/
import { type ActorContextOf, actor, event, queue } from "rivetkit";
import { db, type RawAccess } from "rivetkit/db";
import { interval } from "rivetkit/utils";

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

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,
	}),
	queues: {
		queueForMatch: queue<{
			username: string;
			connId: string;
		}>(),
		unqueueForMatch: queue<{ connId: string }>(),
		matchCompleted: queue<{
			matchId: string;
			winnerUsername: string;
			loserUsername: string;
			winnerNewRating: number;
			loserNewRating: number;
		}>(),
	},
	events: {
		assignmentReady: event<RankedAssignment>(),
		queueUpdate: event<RankedQueueUpdate>(),
	},
	actions: {
		queueForMatch: async (c, { username }: { username: string }) => {
			const connId = c.conn.id;
			await c.queue.send("queueForMatch", {
				username,
				connId,
			});
			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,
			};
		},
	},
	onDisconnect: async (c, conn) => {
		await c.queue.send("unqueueForMatch", { connId: conn.id });
	},
	run: async (c) => {
		c.waitUntil(runQueueUpdateTicker(c));

		while (!c.aborted) {
			const message = await c.queue.next({
				timeout: PAIRING_RETRY_TICK_MS,
			});
			if (!message) {
				// Retry pairing periodically so widening rating windows can form matches
				// even when no new queue messages arrive.
				await attemptPairing(c);
				continue;
			}

			if (message.name === "queueForMatch") {
				const { username, connId } = message.body;

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

				// 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);
			} else if (message.name === "unqueueForMatch") {
				await c.db.execute(
					`DELETE FROM player_pool WHERE conn_id = ?`,
					message.body.connId,
				);
				await sendQueueUpdates(c);
			} else if (message.name === "matchCompleted") {
				const body = message.body;
				const client = c.client<typeof registry>();

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

					const winnerProfile = (await winnerHandle.getProfile()) as {
						username: string;
						rating: number;
						wins: number;
						losses: number;
					};
					const loserProfile = (await loserHandle.getProfile()) as {
						username: string;
						rating: number;
						wins: number;
						losses: number;
					};

					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,
				);
			}
		}
	},
});

async function attemptPairing(c: ActorContextOf<typeof rankedMatchmaker>) {
	const now = Date.now();
	const pool = await c.db.execute<QueuePlayerRow>(
		`SELECT * FROM player_pool ORDER BY queued_at ASC`,
	);

	if (pool.length < 2) return;

	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) {
			const b = pool[bestIdx]!;
			await createRankedMatch(c, a, b);
			return;
		}
	}
}

async function createRankedMatch(
	c: ActorContextOf<typeof rankedMatchmaker>,
	a: QueuePlayerRow,
	b: QueuePlayerRow,
) {
	await c.db.execute(
		`DELETE FROM player_pool WHERE username = ?`,
		a.username,
	);
	await c.db.execute(
		`DELETE FROM player_pool WHERE username = ?`,
		b.username,
	);

	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;

	const client = c.client<typeof registry>();
	await client.rankedMatch.create([matchId], {
		input: {
			matchId,
			assignedPlayers: assignedPlayers.map((p) => ({
				username: p.username,
				rating: p.rating,
			})),
		},
	});

	await c.db.execute(
		`INSERT INTO matches (match_id, created_at) VALUES (?, ?)`,
		matchId,
		Date.now(),
	);

	await sendQueueUpdates(c);

	for (const player of assignedPlayers) {
		await c.db.execute(
			`INSERT OR REPLACE INTO assignments (username, match_id, rating, conn_id) VALUES (?, ?, ?, ?)`,
			player.username,
			matchId,
			player.rating,
			player.connId,
		);
		c.broadcast("assignmentReady", {
			matchId,
			username: player.username,
			rating: player.rating,
			connId: player.connId,
		} satisfies RankedAssignment);
	}
}

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>,
) {
	const tick = interval(QUEUE_UPDATE_TICK_MS);
	while (!c.aborted) {
		await tick();
		if (c.aborted) break;
		await sendQueueUpdates(c);
	}
}

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.
  • run waits for the next queue message with a 2 second timeout. When nothing arrives, it runs attemptPairing anyway, because the windows keep growing while nobody new joins.
  • Writes go through the queue, so queueForMatch, unqueueForMatch, and matchCompleted never interleave. onDisconnect removes a player who closes the tab.

A second loop, started with c.waitUntil(...), 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.

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 { 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,
	TICK_MS,
	WORLD_SIZE,
} from "./config.ts";

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

interface State {
	matchId: string;
	tick: number;
	phase: "waiting" | "live" | "finished";
	players: Record<string, PlayerEntry>;
	winnerId: string | null;
	resultReported: boolean;
}

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

export const rankedMatch = actor({
	options: { name: "Ranked - Match", icon: "ranking-star" },
	events: {
		snapshot: event<Snapshot>(),
		shoot: event<ShootEvent>(),
	},
	createState: (
		_c,
		input: { matchId: string; assignedPlayers: AssignedPlayer[] },
	): State => {
		const players: Record<string, PlayerEntry> = {};
		for (const ap of input.assignedPlayers) {
			players[ap.username] = {
				connId: null,
				color: getPlayerColor(ap.username),
				x: Math.random() * WORLD_SIZE,
				y: Math.random() * WORLD_SIZE,
				lastPositionAt: Date.now(),
				alive: true,
				score: 0,
				rating: ap.rating,
			};
		}
		return {
			matchId: input.matchId,
			tick: 0,
			phase: "waiting",
			players,
			winnerId: null,
			resultReported: false,
		};
	},
	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.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) => {
		await reportMatchCompletedIfNeeded(c, { allowNoResult: true });
	},
	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") {
				await reportMatchCompletedIfNeeded(c);
				continue;
			}
			c.state.tick += 1;
			checkWinCondition(c);
			await reportMatchCompletedIfNeeded(c);
			broadcastSnapshot(c);
		}
	},
	actions: {
		updatePosition: (c, input: { x: number; y: number }) => {
			const found = findPlayerByConnId(c.state, 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.state, 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.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;

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

async function reportMatchCompletedIfNeeded(
	c: ActorContextOf<typeof rankedMatch>,
	options?: { allowNoResult?: boolean },
) {
	const allowNoResult = options?.allowNoResult ?? false;

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

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

	await client.rankedMatchmaker.getOrCreate(["main"]).send("matchCompleted", {
		matchId: c.state.matchId,
		winnerUsername: "",
		loserUsername: "",
		winnerNewRating: 0,
		loserNewRating: 0,
	});
	c.state.resultReported = 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));
}

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.state.players)) {
		players[id] = {
			x: entry.x,
			y: entry.y,
			color: entry.color,
			score: entry.score,
			rating: entry.rating,
		};
	}
	return {
		matchId: c.state.matchId,
		tick: c.state.tick,
		phase: c.state.phase,
		winnerId: c.state.winnerId,
		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;
}
  • 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.
  • run ticks every 50 ms (TICK_MS). Each tick checks the win condition and broadcasts a snapshot.
  • 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:

async function reportMatchCompletedIfNeeded(
	c: ActorContextOf<typeof rankedMatch>,
	options?: { allowNoResult?: boolean },
) {
	const allowNoResult = options?.allowNoResult ?? false;

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

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

	await client.rankedMatchmaker.getOrCreate(["main"]).send("matchCompleted", {
		matchId: c.state.matchId,
		winnerUsername: "",
		loserUsername: "",
		winnerNewRating: 0,
		loserNewRating: 0,
	});
	c.state.resultReported = 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.
  • resultReported makes sure the result is sent once, even though both the tick loop and onDestroy check it. If the match is destroyed without a winner, it sends an empty matchCompleted 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 in c.state, keyed by username, so it outlives every match. applyMatchResult sets the new rating, counts the win or loss, and broadcasts stateUpdate to anyone watching that profile.
  • rankedLeaderboard is one Actor keyed ["main"]. The updatePlayer action only enqueues. The run loop upserts one row at a time into the leaderboard table, then broadcasts the top 20 as leaderboardUpdate.
  • 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.
  • 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.
  • Queues: how queue messages keep the matchmaker’s writes in order.