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.
What we’ll build:
- A
rankedPlayerActor per username that stores the player’s rating, wins, and losses. - A
rankedMatchmakerActor that pairs players by rating and widens each player’s search window the longer they wait. - A
rankedMatchActor per duel that runs a 20 tick per second loop, checks movement, and resolves shots. - Elo rating updates and a
rankedLeaderboardActor 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
getRatingon that player’srankedPlayerActor, 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.
runwaits for the next queue message with a 2 second timeout. When nothing arrives, it runsattemptPairinganyway, because the windows keep growing while nobody new joins.- Writes go through the queue, so
queueForMatch,unqueueForMatch, andmatchCompletednever interleave.onDisconnectremoves 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
waitingand switches toliveinonConnectonce both players are connected. A connection whoseusernameparam is not one of the two assigned players is dropped withinvalid_player. runticks every 50 ms (TICK_MS). Each tick checks the win condition and broadcasts asnapshot.- The client owns its own movement and sends positions, so movement feels instant.
updatePositionnever trusts them: it caps the distance byMAX_SPEEDtimes 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.
shoottakes 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)),
];
}
calculateElouses a K-factor of 32 with the ratings the players had when they were paired.resultReportedmakes sure the result is sent once, even though both the tick loop andonDestroycheck it. If the match is destroyed without a winner, it sends an emptymatchCompletedso the matchmaker still deletes the match’s rows.
The matchmaker then writes the result to each player and to the leaderboard:
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,
);
import { actor, event } from "rivetkit";
import { DEFAULT_RATING } from "./config.ts";
export const rankedPlayer = actor({
options: { name: "Ranked - Player", icon: "ranking-star" },
events: {
stateUpdate: event<PlayerSnapshot>(),
},
state: {
username: "",
rating: DEFAULT_RATING,
wins: 0,
losses: 0,
},
actions: {
initialize: (c, input: { username: string }) => {
if (!c.state.username) {
c.state.username = input.username;
}
},
getProfile: (c): PlayerSnapshot => buildSnapshot(c.state),
getRating: (c): number => c.state.rating,
applyMatchResult: (c, input: { won: boolean; newRating: number }) => {
c.state.rating = input.newRating;
if (input.won) {
c.state.wins += 1;
} else {
c.state.losses += 1;
}
c.broadcast("stateUpdate", buildSnapshot(c.state));
},
},
});
export interface PlayerSnapshot {
username: string;
rating: number;
wins: number;
losses: number;
}
function buildSnapshot(state: {
username: string;
rating: number;
wins: number;
losses: number;
}): PlayerSnapshot {
return {
username: state.username,
rating: state.rating,
wins: state.wins,
losses: state.losses,
};
}
import { type ActorContextOf, actor, event, queue } from "rivetkit";
import { db, type RawAccess } from "rivetkit/db";
export interface LeaderboardEntry {
username: string;
rating: number;
wins: number;
losses: number;
}
export const rankedLeaderboard = actor({
options: { name: "Ranked - Leaderboard", icon: "ranking-star" },
db: db({
onMigrate: migrateTables,
}),
queues: {
updatePlayer: queue<{
username: string;
rating: number;
wins: number;
losses: number;
}>(),
},
events: {
leaderboardUpdate: event<LeaderboardEntry[]>(),
},
actions: {
updatePlayer: async (
c,
input: {
username: string;
rating: number;
wins: number;
losses: number;
},
) => {
await c.queue.send("updatePlayer", input);
},
getTopScores: async (c) => {
return await getTop(c);
},
},
run: async (c) => {
for await (const message of c.queue.iter()) {
if (message.name !== "updatePlayer") continue;
await c.db.execute(
`INSERT INTO leaderboard (username, rating, wins, losses, updated_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(username) DO UPDATE SET rating = ?, wins = ?, losses = ?, updated_at = ?`,
message.body.username,
message.body.rating,
message.body.wins,
message.body.losses,
Date.now(),
message.body.rating,
message.body.wins,
message.body.losses,
Date.now(),
);
const top = await getTop(c);
c.broadcast("leaderboardUpdate", top);
}
},
});
async function getTop(
c: ActorContextOf<typeof rankedLeaderboard>,
): Promise<LeaderboardEntry[]> {
const rows = await c.db.execute<{
username: string;
rating: number;
wins: number;
losses: number;
}>(
`SELECT username, rating, wins, losses FROM leaderboard ORDER BY rating DESC LIMIT 20`,
);
return rows.map((r) => ({
username: r.username,
rating: r.rating,
wins: r.wins,
losses: r.losses,
}));
}
async function migrateTables(dbHandle: RawAccess) {
await dbHandle.execute(`
CREATE TABLE IF NOT EXISTS leaderboard (
username TEXT PRIMARY KEY,
rating INTEGER NOT NULL,
wins INTEGER NOT NULL DEFAULT 0,
losses INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL
)
`);
}
rankedPlayerkeeps the rating inc.state, keyed by username, so it outlives every match.applyMatchResultsets the new rating, counts the win or loss, and broadcastsstateUpdateto anyone watching that profile.rankedLeaderboardis one Actor keyed["main"]. TheupdatePlayeraction only enqueues. Therunloop upserts one row at a time into theleaderboardtable, then broadcasts the top 20 asleaderboardUpdate.- The leaderboard copies rating, wins, and losses from
getProfile. TherankedPlayerActor 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:
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. 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:
const queueForMatch = async () => {
if (!username.trim()) return;
setStatus("queuing");
setError("");
abortRef.current = false;
matchedRef.current = false;
try {
const mm = client.rankedMatchmaker.getOrCreate(["main"]).connect();
mmRef.current = mm;
let matched = false;
const resolveMatch = (assignment: RankedMatchInfo) => {
if (matched || abortRef.current) return;
matched = true;
matchedRef.current = true;
setStatus("matched");
setTimeout(async () => {
await cleanup(false);
onReady(assignment);
}, 1500);
};
mm.on("queueUpdate", (data: RankedQueueUpdate) => {
setQueueCount(data.count ?? 0);
if (!data.queued) {
setQueueDurationMs(0);
setRatingWindow(null);
setRatingRange(null);
return;
}
setQueueDurationMs(data.queueDurationMs ?? 0);
if (typeof data.ratingWindow === "number") {
setRatingWindow(data.ratingWindow);
} else {
setRatingWindow(null);
}
if (
typeof data.ratingMin === "number" &&
typeof data.ratingMax === "number"
) {
setRatingRange({
min: data.ratingMin,
max: data.ratingMax,
});
} else {
setRatingRange(null);
}
});
setStatus("queued");
const queueResult = await mm.queueForMatch({ username });
if (abortRef.current) {
throw new Error("Failed to queue");
}
const assignment = await waitForAssignment(
mm,
username,
queueResult.connId,
);
resolveMatch(assignment);
} catch (err) {
if (abortRef.current) return;
await cleanup();
setError(err instanceof Error ? err.message : String(err));
setStatus("error");
}
};
import type { RankedMatchConn } from "../../actor-types.ts";
import type { GameClient } from "../../client.ts";
import type { RankedMatchInfo } from "./menu.tsx";
const PLAYER_RADIUS = 12;
const LERP_FACTOR = 0.2;
const GRID_SPACING = 50;
const SEND_RATE_MS = 50;
const MOVE_SPEED = 200;
const SHOT_LINE_DURATION = 150;
const RUBBER_BAND_THRESHOLD = 40;
interface ShotLine {
fromX: number;
fromY: number;
toX: number;
toY: number;
hit: boolean;
createdAt: number;
}
export class RankedGame {
private stopped = false;
private rafId = 0;
private worldSize = 600;
private targets: Record<
string,
{ x: number; y: number; color: string; score: number; rating: number }
> = {};
private display: Record<string, { x: number; y: number }> = {};
private keys: Record<string, boolean> = {};
private phase: "waiting" | "live" | "finished" = "waiting";
private winnerId: string | null = null;
private scoreLimit = 5;
private shotLines: ShotLine[] = [];
private lastSendTime = 0;
private localX = 300;
private localY = 300;
private lastFrameTime = 0;
private botInterval = 0;
private conn: RankedMatchConn;
constructor(
private canvas: HTMLCanvasElement | null,
client: GameClient,
private matchInfo: RankedMatchInfo,
private options: { bot?: boolean } = {},
) {
this.conn = client.rankedMatch
.get([matchInfo.matchId], {
params: { username: matchInfo.username },
})
.connect();
this.conn.on("snapshot", (snap) => {
this.worldSize = snap.worldSize;
this.phase = snap.phase;
this.winnerId = snap.winnerId;
this.scoreLimit = snap.scoreLimit;
for (const [id, data] of Object.entries(snap.players)) {
this.targets[id] = data;
if (id === this.matchInfo.username) {
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 };
}
}
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) => {
this.shotLines.push({
fromX: shot.fromX,
fromY: shot.fromY,
toX: shot.fromX + shot.dirX * 300,
toY: shot.fromY + shot.dirY * 300,
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;
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,
),
);
}
if (now - this.lastSendTime >= SEND_RATE_MS) {
this.lastSendTime = now;
this.conn
.updatePosition({ x: this.localX, y: this.localY })
.catch(() => {});
}
}
ctx.fillStyle = "#000000";
ctx.fillRect(0, 0, canvas.width, canvas.height);
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();
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();
}
for (const [id, target] of Object.entries(this.targets)) {
const isMe = id === this.matchInfo.username;
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();
}
ctx.fillStyle = "#ffffff";
ctx.font = "11px sans-serif";
ctx.textAlign = "center";
ctx.fillText(
`${id} [${target.score}/${this.scoreLimit}] R:${target.rating}`,
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.username];
const scoreText = myData
? `Score: ${myData.score}/${this.scoreLimit} Rating: ${myData.rating}`
: "";
ctx.fillText(`${this.phase.toUpperCase()} ${scoreText}`, 8, 18);
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 opponent...",
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";
const winText =
this.winnerId === this.matchInfo.username
? "You Win!"
: "You Lose!";
ctx.fillText(winText, canvas.width / 2, canvas.height / 2);
}
this.rafId = requestAnimationFrame(this.draw);
};
}
queueForMatchreturns the connection id.waitForAssignmentignores anyassignmentReadyfor another connection, so two tabs with the same username do not both take the match. It also callsgetAssignmentonce, in case the event fired before the listener was attached.- The game connects with
params: { username }, which is howonConnectfinds the player. - The local player moves every frame and sends
updatePositionevery 50 ms. When asnapshotputs 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.