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 in SQLite. - 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, 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
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.
queueForMatchadds the player toplayer_pooland callsattemptPairingright away. The windows keep growing while nobody new joins, soonWakealso startsrunPairingRetryTicker, which callsattemptPairingevery 2 seconds.- Actions and the retry loop run at the same time, so
attemptPairingpicks a pair and removes both players fromplayer_poolin onec.db.transaction. Two pairings at once can’t claim the same player. createRankedMatchcreates therankedMatchActor after the transaction commits, because a transaction must not wait on another Actor. If creating it fails,returnToPoolputs the players who are still connected back in their old places.onDisconnectremoves 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;
}
onCreatesaves both usernames, ratings, and random spawn points to the match’s SQLite database once.createVarsloads them intoc.varson every start, with the phase, tick, positions, scores, andresultReportedfrom the last save. Both players start without a connection, because none survives a restart.- 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. onWakestartsrunMatchas a background task on every start, andonSleepwaits for it to save and exit. It ticks every 50 ms (TICK_MS). Each tick checks the win condition and broadcasts asnapshot. 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,saveMatchwrites the match and both players in onec.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.
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:
/** 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)),
];
}
calculateElouses a K-factor of 32 with the ratings the players had when they were paired.matchCompletedis an action on the matchmaker, so the call returns once both players and the leaderboard have the result.resultReportedkeeps the match from sending the result again once it went through, even though both the tick loop andonDestroycheck it.onDestroywaits 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
retryReportschedule afterREPORT_RETRY_MSinstead of stopping the loop. A schedule wakes the match even after both players have left and it went to sleep. EachrankedPlayerrecords 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
matchCompletedwith empty usernames so the matchmaker still deletes the match’s rows.
The matchmaker then writes the result to each player and to the leaderboard:
// 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,
);
},
import { actor, event } from "rivetkit";
import { db } from "rivetkit/db";
import { DEFAULT_RATING } from "./config.ts";
export const rankedPlayer = actor({
options: { name: "Ranked - Player", icon: "ranking-star" },
events: {
stateUpdate: event<PlayerSnapshot>(),
},
db: db({
onMigrate: async (db) => {
await db.execute(`
CREATE TABLE IF NOT EXISTS profile (
id INTEGER PRIMARY KEY CHECK (id = 1),
username TEXT NOT NULL,
rating INTEGER NOT NULL,
wins INTEGER NOT NULL,
losses INTEGER NOT NULL
)
`);
await db.execute(
"INSERT OR IGNORE INTO profile (id, username, rating, wins, losses) VALUES (1, '', ?, 0, 0)",
DEFAULT_RATING,
);
await db.execute(`
CREATE TABLE IF NOT EXISTS applied_matches (
match_id TEXT PRIMARY KEY
)
`);
},
}),
actions: {
initialize: async (c, input: { username: string }) => {
// Only the first call sets the username.
await c.db.execute(
"UPDATE profile SET username = ? WHERE id = 1 AND username = ''",
input.username,
);
},
getProfile: async (c): Promise<PlayerSnapshot> => {
const [profile] = await c.db.execute<PlayerSnapshot>(
"SELECT username, rating, wins, losses FROM profile WHERE id = 1",
);
return profile!;
},
getRating: async (c): Promise<number> => {
const [profile] = await c.db.execute<{ rating: number }>(
"SELECT rating FROM profile WHERE id = 1",
);
return profile!.rating;
},
applyMatchResult: async (
c,
input: { matchId: string; won: boolean; newRating: number },
) => {
// Record the match id in the same transaction as the result, so a
// report that is sent again is applied only once. The win or loss
// is added to the saved row, so two different results at once are
// both counted.
const profile = await c.db.transaction(
async (tx) => {
const applied = await tx.execute(
"INSERT INTO applied_matches (match_id) VALUES (?) ON CONFLICT DO NOTHING RETURNING match_id",
input.matchId,
);
if (applied.length === 0) return null;
const [updated] = await tx.execute<PlayerSnapshot>(
"UPDATE profile SET rating = ?, wins = wins + ?, losses = losses + ? WHERE id = 1 RETURNING username, rating, wins, losses",
input.newRating,
input.won ? 1 : 0,
input.won ? 0 : 1,
);
return updated!;
},
{ name: "apply-match-result" },
);
if (profile) c.broadcast("stateUpdate", profile);
},
},
});
export type PlayerSnapshot = {
username: string;
rating: number;
wins: number;
losses: number;
};
import { type ActorContextOf, actor, event } 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,
}),
events: {
leaderboardUpdate: event<LeaderboardEntry[]>(),
},
actions: {
updatePlayer: async (
c,
input: {
username: string;
rating: number;
wins: number;
losses: number;
},
) => {
// Two matches for one player can finish at once and report out of
// order. Every result adds one win or loss, so only a profile with
// at least as many games as the saved row replaces it.
await c.db.execute(
`INSERT INTO leaderboard (username, rating, wins, losses, updated_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(username) DO UPDATE SET rating = excluded.rating, wins = excluded.wins, losses = excluded.losses, updated_at = excluded.updated_at
WHERE excluded.wins + excluded.losses >= leaderboard.wins + leaderboard.losses`,
input.username,
input.rating,
input.wins,
input.losses,
Date.now(),
);
const top = await getTop(c);
c.broadcast("leaderboardUpdate", top);
},
getTopScores: async (c) => {
return await getTop(c);
},
},
});
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, wins, and losses in a one-rowprofiletable in its SQLite database, keyed by username, so it outlives every match.applyMatchResultrecords the match id inapplied_matchesand, only if it is new, sets the new rating and adds the win or loss in oneapply-match-resulttransaction. Two different results at once are both counted, and the same result sent twice counts once. It then broadcastsstateUpdateto anyone watching that profile.rankedLeaderboardis one Actor keyed["main"].updatePlayerupserts the player’s row into theleaderboardtable in one statement, then broadcasts the top 20 asleaderboardUpdate.- 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. 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. That happens wheneverqueueForMatchitself completes the pair.- 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.
- Transactions: how the matchmaker claims a pair when requests overlap.