IO Game
Build an IO game like Agar.io or Slither.io, where players drop into an open lobby at any time and the server moves everyone.
What we’ll build:
- An
ioStyleMatchmakerActor that sends each player to the fullest lobby with room, and opens a new lobby when every lobby is full. - An
ioStyleMatchActor per lobby that moves every player on the server 10 times per second. - Clients that only send which way they want to go. The server decides where everyone is.
- Interpolation in the browser, so 10 snapshots per second still draw smoothly.
Install
npm add rivetkit
Create the matchmaker
One ioStyleMatchmaker Actor, keyed ["main"], decides which lobby each player joins. It keeps lobbies and seat reservations in its SQLite database:
/*
This matchmaker uses a two phase open lobby flow.
1. findLobby claims a slot immediately and returns matchId and playerId.
2. The client connects to the match actor with playerId.
3. The match actor claims that pending player through pendingPlayerConnected before first join.
4. Pending players expire after JOIN_RESERVATION_TTL_MS, which removes never connected players.
5. updateMatch reports occupied player count while player_count stays pending + occupied.
*/
import { type ActorContextOf, actor } from "rivetkit";
import { db, type RawAccess } from "rivetkit/db";
import type { registry } from "../index.ts";
import { CAPACITY } from "./config.ts";
const JOIN_RESERVATION_TTL_MS = 15_000;
interface MatchmakerVars {
// The lobby being created right now, so requests that find every lobby
// full at the same time wait for it instead of opening one each
openingLobby: Promise<void> | null;
}
export const ioStyleMatchmaker = actor({
options: { name: "IO - Matchmaker", icon: "earth-americas" },
db: db({
onMigrate: migrateTables,
}),
createVars: (): MatchmakerVars => ({ openingLobby: null }),
actions: {
findLobby: async (
c,
): Promise<{ matchId: string; playerId: string }> => {
const playerId = crypto.randomUUID();
let matchId = await reserveSeat(c, playerId);
while (matchId === null) {
await openLobby(c);
matchId = await reserveSeat(c, playerId);
}
return { matchId, playerId };
},
pendingPlayerConnected: async (
c,
input: { matchId: string; playerId: string },
): Promise<{ accepted: boolean }> => {
const now = Date.now();
return await c.db.transaction(
async (tx) => {
// Deleting the reservation is what claims it, so a player
// who connects twice at once is accepted only once.
const rows = await tx.execute<{ expires_at: number }>(
`DELETE FROM pending_players WHERE match_id = ? AND player_id = ? RETURNING expires_at`,
input.matchId,
input.playerId,
);
const reservation = rows[0];
if (!reservation) {
return { accepted: false };
}
const accepted = reservation.expires_at > now;
if (accepted) {
// Count the seat as occupied right away. Otherwise it would
// look free until the match reports its new count.
await tx.execute(
`UPDATE matches SET connected_player_count = connected_player_count + 1 WHERE match_id = ?`,
input.matchId,
);
}
await syncClaimedPlayerCount(tx, input.matchId, now);
return { accepted };
},
{ name: "claim-seat" },
);
},
updateMatch: async (
c,
input: { matchId: string; connectedPlayerCount: number },
) => {
const now = Date.now();
await c.db.transaction(
async (tx) => {
await tx.execute(
`UPDATE matches SET connected_player_count = ?, updated_at = ? WHERE match_id = ?`,
input.connectedPlayerCount,
now,
input.matchId,
);
await syncClaimedPlayerCount(tx, input.matchId, now);
},
{ name: "update-match" },
);
},
closeMatch: async (c, input: { matchId: string }) => {
// Delete both in one transaction, so findLobby can't reserve a seat
// in a match that is halfway closed.
await c.db.transaction(
async (tx) => {
await tx.execute(
`DELETE FROM pending_players WHERE match_id = ?`,
input.matchId,
);
await tx.execute(
`DELETE FROM matches WHERE match_id = ?`,
input.matchId,
);
},
{ name: "close-match" },
);
},
},
});
/**
* Reserves a seat for `playerId` in the fullest lobby with room and returns
* its match id, or null when every lobby is full.
*/
async function reserveSeat(
c: ActorContextOf<typeof ioStyleMatchmaker>,
playerId: string,
): Promise<string | null> {
const now = Date.now();
// Actions run concurrently, so pick the lobby and take the seat in one
// transaction. Two players asking at once can't both take the last seat.
return await c.db.transaction(
async (tx) => {
await expirePendingPlayers(tx, now);
const rows = await tx.execute<{ match_id: string }>(
`SELECT match_id FROM matches WHERE player_count < ? ORDER BY player_count DESC, updated_at DESC LIMIT 1`,
CAPACITY,
);
const matchId = rows[0]?.match_id;
if (!matchId) {
return null;
}
await tx.execute(
`INSERT INTO pending_players (match_id, player_id, expires_at, created_at) VALUES (?, ?, ?, ?)`,
matchId,
playerId,
now + JOIN_RESERVATION_TTL_MS,
now,
);
await syncClaimedPlayerCount(tx, matchId, now);
return matchId;
},
{ name: "reserve-seat" },
);
}
/**
* Opens one new empty lobby. Requests that find every lobby full at the same
* time share the lobby that is already being opened.
*/
function openLobby(c: ActorContextOf<typeof ioStyleMatchmaker>): Promise<void> {
if (!c.vars.openingLobby) {
c.vars.openingLobby = createLobby(c).finally(() => {
c.vars.openingLobby = null;
});
}
return c.vars.openingLobby;
}
async function createLobby(
c: ActorContextOf<typeof ioStyleMatchmaker>,
): Promise<void> {
const matchId = crypto.randomUUID();
// Create the match before saving its row, so findLobby never hands out a
// match that doesn't exist yet. This can't run inside a transaction,
// because a transaction must not wait on another actor.
const client = c.client<typeof registry>();
await client.ioStyleMatch.create([matchId]);
await c.db.execute(
`INSERT INTO matches (match_id, player_count, connected_player_count, updated_at) VALUES (?, ?, ?, ?)`,
matchId,
0,
0,
Date.now(),
);
}
async function expirePendingPlayers(tx: RawAccess, now: number): Promise<void> {
const rows = await tx.execute<{ match_id: string }>(
`SELECT DISTINCT match_id FROM pending_players WHERE expires_at <= ?`,
now,
);
if (rows.length === 0) return;
await tx.execute(`DELETE FROM pending_players WHERE expires_at <= ?`, now);
for (const row of rows) {
await syncClaimedPlayerCount(tx, row.match_id, now);
}
}
async function syncClaimedPlayerCount(
tx: RawAccess,
matchId: string,
now: number,
): Promise<void> {
await tx.execute(
`UPDATE matches
SET player_count = connected_player_count + COALESCE(
(SELECT COUNT(*) FROM pending_players WHERE match_id = ?),
0
),
updated_at = ?
WHERE match_id = ?`,
matchId,
now,
matchId,
);
}
async function migrateTables(dbHandle: RawAccess) {
await dbHandle.execute(`
CREATE TABLE IF NOT EXISTS matches (
match_id TEXT PRIMARY KEY,
player_count INTEGER NOT NULL,
connected_player_count INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL
)
`);
await ensureColumn(
dbHandle,
"matches",
"connected_player_count",
"INTEGER NOT NULL DEFAULT 0",
);
await ensureColumn(
dbHandle,
"matches",
"updated_at",
"INTEGER NOT NULL DEFAULT 0",
);
await dbHandle.execute(`
CREATE TABLE IF NOT EXISTS pending_players (
match_id TEXT NOT NULL,
player_id TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (match_id, player_id)
)
`);
await dbHandle.execute(
"CREATE INDEX IF NOT EXISTS matches_open_idx ON matches (player_count, updated_at)",
);
await dbHandle.execute(
"CREATE INDEX IF NOT EXISTS pending_players_match_idx ON pending_players (match_id)",
);
await dbHandle.execute(
"CREATE INDEX IF NOT EXISTS pending_players_expire_idx ON pending_players (expires_at)",
);
}
async function ensureColumn(
dbHandle: RawAccess,
table: "matches",
column: "connected_player_count" | "updated_at",
definition: "INTEGER NOT NULL DEFAULT 0",
) {
const columns = await dbHandle.execute<{ name: string }>(
`PRAGMA table_info(${table})`,
);
if (!columns.some((col) => col.name === column)) {
await dbHandle.execute(
`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`,
);
}
}
findLobby,pendingPlayerConnected,updateMatch, andcloseMatchare actions. Actions run at the same time, so each one reads and changes the tables inside onec.db.transaction.reserveSeatpicks the lobby with the most players that is still belowCAPACITY(32) and reserves a seat in the same transaction, so two players asking at the same moment can’t both take the last seat. There is no “started” flag, because an IO lobby never closes to new players.- If every lobby is full,
openLobbycreates anioStyleMatchActor keyed by a new match id, inserts its row, andfindLobbytries again. The Actor is created outside the transaction, because a transaction must not wait on another Actor. Requests that find every lobby full at the same time wait for the same new lobby inc.vars.openingLobbyinstead of opening one each. findLobbyreturns a newplayerIdand saves it inpending_playersfor 15 seconds.player_countcounts pending players as well as connected ones, so a burst of players can’t overfill a lobby before they connect.pendingPlayerConnectedclaims a reservation by deleting it withDELETE ... RETURNING, so eachplayerIdis accepted only once. The same transaction counts the seat as connected, so it never looks free before the match reports its new count.- A reservation that is never claimed expires at the start of the next
findLobby, inexpirePendingPlayers, which frees the seat.
Create the match
Each lobby is its own ioStyleMatch Actor, keyed by the match id. A player joins by connecting with the playerId the matchmaker gave them:
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 { CAPACITY, SPEED, WORLD_SIZE } from "./config.ts";
const TICK_MS = 100;
const SNAPSHOT_INTERVAL_MS = 1_000; // Saving this often bounds how much progress a crash can lose.
const DISCONNECT_GRACE_MS = 5000;
interface PlayerEntry {
connId: string | null;
color: string;
x: number;
y: number;
inputX: number;
inputY: number;
disconnectedAt: number | null;
}
interface MatchVars {
matchId: string;
tick: number;
players: Record<string, PlayerEntry>;
// `onWake` stores its loop here so `onSleep` can wait for it.
loop: Promise<void> | null;
}
interface ConnParams {
playerId?: string;
}
type PlayerRow = {
player_id: string;
x: number;
y: number;
};
export const ioStyleMatch = actor({
options: { name: "IO - Match", icon: "earth-americas" },
events: {
snapshot: event<Snapshot>(),
},
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
)
`);
await db.execute(`
CREATE TABLE IF NOT EXISTS players (
player_id TEXT PRIMARY KEY,
x REAL NOT NULL,
y REAL NOT NULL
)
`);
},
}),
// The match runs in memory and starts from the last save. Its key is the
// match id.
createVars: async (c): Promise<MatchVars> => {
const [info] = await c.db.execute<{ tick: number }>(
"SELECT tick FROM match_info WHERE id = 1",
);
const rows = await c.db.execute<PlayerRow>(
"SELECT player_id, x, y FROM players",
);
// No connection survives a restart, so every claimed player starts
// disconnected and is dropped if they don't reconnect in time.
const players: Record<string, PlayerEntry> = {};
for (const row of rows) {
players[row.player_id] = {
connId: null,
color: getPlayerColor(row.player_id),
x: row.x,
y: row.y,
inputX: 0,
inputY: 0,
disconnectedAt: Date.now(),
};
}
return {
matchId: c.key[0]!,
tick: info?.tick ?? 0,
players,
loop: null,
};
},
onConnect: async (c, conn) => {
const params = (conn.params as ConnParams | null) ?? {};
const playerId = params.playerId;
if (!playerId) {
conn.disconnect("invalid_player");
return;
}
const existingPlayer = c.vars.players[playerId];
if (existingPlayer) {
existingPlayer.connId = conn.id;
existingPlayer.disconnectedAt = null;
} else {
const confirmed = await claimPendingPlayer(c, playerId);
if (!confirmed) {
conn.disconnect("player_not_pending");
return;
}
c.vars.players[playerId] = {
connId: conn.id,
color: getPlayerColor(playerId),
x: Math.random() * WORLD_SIZE,
y: Math.random() * WORLD_SIZE,
inputX: 0,
inputY: 0,
disconnectedAt: null,
};
// The matchmaker has used up this player's reservation, so save
// the player now. A crash before the next save would otherwise
// turn them away with player_not_pending when they reconnect.
await saveMatch(c);
}
await updateMatchmaker(c);
broadcastSnapshot(c);
},
onDestroy: async (c) => {
// `c.abortSignal` also fires on destroy. Let the loop finish its last
// save before the database closes.
await c.vars.loop;
const client = c.client<typeof registry>();
await client.ioStyleMatchmaker
.getOrCreate(["main"])
.closeMatch({ matchId: c.vars.matchId });
},
onDisconnect: async (c, conn) => {
const found = findPlayerByConnId(c.vars, conn.id);
if (!found) return;
const [, player] = found;
player.connId = null;
player.disconnectedAt = Date.now();
await updateMatchmaker(c);
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: {
setInput: (c, input: { inputX: number; inputY: number }) => {
const found = findPlayerByConnId(c.vars, c.conn.id);
if (!found) {
throw new UserError("player not found", {
code: "player_not_found",
});
}
const [, player] = found;
player.inputX = Math.max(-1, Math.min(1, input.inputX));
player.inputY = Math.max(-1, Math.min(1, input.inputY));
},
getSnapshot: (c) => buildSnapshot(c),
},
});
function broadcastSnapshot(c: ActorContextOf<typeof ioStyleMatch>) {
c.broadcast("snapshot", buildSnapshot(c));
}
/** Steps the match every `TICK_MS` until the actor starts shutting down. */
async function runMatch(c: ActorContextOf<typeof ioStyleMatch>): Promise<void> {
const tick = interval(TICK_MS);
let savedAt = Date.now();
while (!c.aborted) {
await tick();
if (c.aborted) break;
c.vars.tick += 1;
// The grace period is much shorter than the idle timeout, so an empty
// match removes its last players here before it goes to sleep.
const now = Date.now();
let removedPlayer = false;
for (const [id, player] of Object.entries(c.vars.players)) {
if (
player.disconnectedAt &&
now - player.disconnectedAt > DISCONNECT_GRACE_MS
) {
delete c.vars.players[id];
removedPlayer = true;
continue;
}
player.x = Math.max(
0,
Math.min(WORLD_SIZE, player.x + SPEED * player.inputX),
);
player.y = Math.max(
0,
Math.min(WORLD_SIZE, player.y + SPEED * player.inputY),
);
}
if (removedPlayer) {
await updateMatchmaker(c);
}
broadcastSnapshot(c);
// Ticks only change memory. Save to SQLite every
// SNAPSHOT_INTERVAL_MS instead of on every tick.
if (now - savedAt >= SNAPSHOT_INTERVAL_MS) {
await saveMatch(c);
savedAt = now;
}
}
// Save once more when the Actor stops, so a clean restart loses nothing.
await saveMatch(c);
}
/**
* Writes the tick and every claimed player's position 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 ioStyleMatch>) {
// Copy the values first, because ticks can run while the transaction
// waits on SQLite.
const tick = c.vars.tick;
const players: PlayerRow[] = Object.entries(c.vars.players).map(
([playerId, player]) => ({
player_id: playerId,
x: player.x,
y: player.y,
}),
);
await c.db.transaction(
async (tx) => {
await tx.execute(
"INSERT OR REPLACE INTO match_info (id, tick) VALUES (1, ?)",
tick,
);
await tx.execute("DELETE FROM players");
for (const player of players) {
await tx.execute(
"INSERT INTO players (player_id, x, y) VALUES (?, ?, ?)",
player.player_id,
player.x,
player.y,
);
}
},
{ name: "save-match" },
);
}
/**
* Tells the matchmaker how full this match is. The count only steers new
* players to a lobby, and the next join or leave sends it again, so a failed
* update is logged instead of stopping the caller or the tick loop.
*/
async function updateMatchmaker(c: ActorContextOf<typeof ioStyleMatch>) {
const client = c.client<typeof registry>();
try {
await client.ioStyleMatchmaker.getOrCreate(["main"]).updateMatch({
matchId: c.vars.matchId,
connectedPlayerCount: occupiedPlayerCount(c.vars),
});
} catch (error) {
c.log.warn({ msg: "updating the matchmaker failed", error });
}
}
async function claimPendingPlayer(
c: ActorContextOf<typeof ioStyleMatch>,
playerId: string,
): Promise<boolean> {
const client = c.client<typeof registry>();
const result = await client.ioStyleMatchmaker
.getOrCreate(["main"])
.pendingPlayerConnected({ matchId: c.vars.matchId, playerId });
return result.accepted;
}
interface Snapshot {
matchId: string;
capacity: number;
tick: number;
playerCount: number;
worldSize: number;
players: Record<string, { x: number; y: number; color: string }>;
}
function buildSnapshot(c: ActorContextOf<typeof ioStyleMatch>): Snapshot {
const players: Record<string, { x: number; y: number; color: string }> = {};
for (const [id, entry] of Object.entries(c.vars.players)) {
if (entry.disconnectedAt) continue;
players[id] = { x: entry.x, y: entry.y, color: entry.color };
}
return {
matchId: c.vars.matchId,
capacity: CAPACITY,
tick: c.vars.tick,
playerCount: activePlayerCount(c.vars),
worldSize: WORLD_SIZE,
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;
}
function activePlayerCount(vars: MatchVars): number {
let count = 0;
for (const player of Object.values(vars.players)) {
if (!player.disconnectedAt) count++;
}
return count;
}
function occupiedPlayerCount(vars: MatchVars): number {
return Object.keys(vars.players).length;
}
onConnectreadsplayerIdfrom the connection params. A new player has to be claimed from the matchmaker withpendingPlayerConnectedfirst. An unknown or expired id is disconnected withplayer_not_pending.claimPendingPlayercalls the matchmaker’spendingPlayerConnectedaction and returns its answer. If the matchmaker can’t be reached, the call throws and the connection fails instead of turning a valid player away.- New players spawn at a random point. A player who reconnects keeps their position, with the new connection id.
- A claim uses up the reservation, so
onConnectsaves the new player to SQLite right away. After a crash,createVarsrestores every claimed player without a connection, so a player who reconnects gets their seat back instead ofplayer_not_pending. - After every join and leave,
updateMatchmakercallsupdateMatchwith the number of seats in use, so the matchmaker’s counts stay correct as players come and go.
The playerId is the only credential in this example. Anyone who knows it can connect as that player. In production, have the matchmaker issue a signed token and check it in the match, or use authentication.
Simulate movement on the server
The match moves every player itself. Clients only tell it which way they want to go:
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 { CAPACITY, SPEED, WORLD_SIZE } from "./config.ts";
const TICK_MS = 100;
const SNAPSHOT_INTERVAL_MS = 1_000; // Saving this often bounds how much progress a crash can lose.
const DISCONNECT_GRACE_MS = 5000;
interface PlayerEntry {
connId: string | null;
color: string;
x: number;
y: number;
inputX: number;
inputY: number;
disconnectedAt: number | null;
}
interface MatchVars {
matchId: string;
tick: number;
players: Record<string, PlayerEntry>;
// `onWake` stores its loop here so `onSleep` can wait for it.
loop: Promise<void> | null;
}
interface ConnParams {
playerId?: string;
}
type PlayerRow = {
player_id: string;
x: number;
y: number;
};
export const ioStyleMatch = actor({
options: { name: "IO - Match", icon: "earth-americas" },
events: {
snapshot: event<Snapshot>(),
},
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
)
`);
await db.execute(`
CREATE TABLE IF NOT EXISTS players (
player_id TEXT PRIMARY KEY,
x REAL NOT NULL,
y REAL NOT NULL
)
`);
},
}),
// The match runs in memory and starts from the last save. Its key is the
// match id.
createVars: async (c): Promise<MatchVars> => {
const [info] = await c.db.execute<{ tick: number }>(
"SELECT tick FROM match_info WHERE id = 1",
);
const rows = await c.db.execute<PlayerRow>(
"SELECT player_id, x, y FROM players",
);
// No connection survives a restart, so every claimed player starts
// disconnected and is dropped if they don't reconnect in time.
const players: Record<string, PlayerEntry> = {};
for (const row of rows) {
players[row.player_id] = {
connId: null,
color: getPlayerColor(row.player_id),
x: row.x,
y: row.y,
inputX: 0,
inputY: 0,
disconnectedAt: Date.now(),
};
}
return {
matchId: c.key[0]!,
tick: info?.tick ?? 0,
players,
loop: null,
};
},
onConnect: async (c, conn) => {
const params = (conn.params as ConnParams | null) ?? {};
const playerId = params.playerId;
if (!playerId) {
conn.disconnect("invalid_player");
return;
}
const existingPlayer = c.vars.players[playerId];
if (existingPlayer) {
existingPlayer.connId = conn.id;
existingPlayer.disconnectedAt = null;
} else {
const confirmed = await claimPendingPlayer(c, playerId);
if (!confirmed) {
conn.disconnect("player_not_pending");
return;
}
c.vars.players[playerId] = {
connId: conn.id,
color: getPlayerColor(playerId),
x: Math.random() * WORLD_SIZE,
y: Math.random() * WORLD_SIZE,
inputX: 0,
inputY: 0,
disconnectedAt: null,
};
// The matchmaker has used up this player's reservation, so save
// the player now. A crash before the next save would otherwise
// turn them away with player_not_pending when they reconnect.
await saveMatch(c);
}
await updateMatchmaker(c);
broadcastSnapshot(c);
},
onDestroy: async (c) => {
// `c.abortSignal` also fires on destroy. Let the loop finish its last
// save before the database closes.
await c.vars.loop;
const client = c.client<typeof registry>();
await client.ioStyleMatchmaker
.getOrCreate(["main"])
.closeMatch({ matchId: c.vars.matchId });
},
onDisconnect: async (c, conn) => {
const found = findPlayerByConnId(c.vars, conn.id);
if (!found) return;
const [, player] = found;
player.connId = null;
player.disconnectedAt = Date.now();
await updateMatchmaker(c);
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: {
setInput: (c, input: { inputX: number; inputY: number }) => {
const found = findPlayerByConnId(c.vars, c.conn.id);
if (!found) {
throw new UserError("player not found", {
code: "player_not_found",
});
}
const [, player] = found;
player.inputX = Math.max(-1, Math.min(1, input.inputX));
player.inputY = Math.max(-1, Math.min(1, input.inputY));
},
getSnapshot: (c) => buildSnapshot(c),
},
});
function broadcastSnapshot(c: ActorContextOf<typeof ioStyleMatch>) {
c.broadcast("snapshot", buildSnapshot(c));
}
/** Steps the match every `TICK_MS` until the actor starts shutting down. */
async function runMatch(c: ActorContextOf<typeof ioStyleMatch>): Promise<void> {
const tick = interval(TICK_MS);
let savedAt = Date.now();
while (!c.aborted) {
await tick();
if (c.aborted) break;
c.vars.tick += 1;
// The grace period is much shorter than the idle timeout, so an empty
// match removes its last players here before it goes to sleep.
const now = Date.now();
let removedPlayer = false;
for (const [id, player] of Object.entries(c.vars.players)) {
if (
player.disconnectedAt &&
now - player.disconnectedAt > DISCONNECT_GRACE_MS
) {
delete c.vars.players[id];
removedPlayer = true;
continue;
}
player.x = Math.max(
0,
Math.min(WORLD_SIZE, player.x + SPEED * player.inputX),
);
player.y = Math.max(
0,
Math.min(WORLD_SIZE, player.y + SPEED * player.inputY),
);
}
if (removedPlayer) {
await updateMatchmaker(c);
}
broadcastSnapshot(c);
// Ticks only change memory. Save to SQLite every
// SNAPSHOT_INTERVAL_MS instead of on every tick.
if (now - savedAt >= SNAPSHOT_INTERVAL_MS) {
await saveMatch(c);
savedAt = now;
}
}
// Save once more when the Actor stops, so a clean restart loses nothing.
await saveMatch(c);
}
/**
* Writes the tick and every claimed player's position 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 ioStyleMatch>) {
// Copy the values first, because ticks can run while the transaction
// waits on SQLite.
const tick = c.vars.tick;
const players: PlayerRow[] = Object.entries(c.vars.players).map(
([playerId, player]) => ({
player_id: playerId,
x: player.x,
y: player.y,
}),
);
await c.db.transaction(
async (tx) => {
await tx.execute(
"INSERT OR REPLACE INTO match_info (id, tick) VALUES (1, ?)",
tick,
);
await tx.execute("DELETE FROM players");
for (const player of players) {
await tx.execute(
"INSERT INTO players (player_id, x, y) VALUES (?, ?, ?)",
player.player_id,
player.x,
player.y,
);
}
},
{ name: "save-match" },
);
}
/**
* Tells the matchmaker how full this match is. The count only steers new
* players to a lobby, and the next join or leave sends it again, so a failed
* update is logged instead of stopping the caller or the tick loop.
*/
async function updateMatchmaker(c: ActorContextOf<typeof ioStyleMatch>) {
const client = c.client<typeof registry>();
try {
await client.ioStyleMatchmaker.getOrCreate(["main"]).updateMatch({
matchId: c.vars.matchId,
connectedPlayerCount: occupiedPlayerCount(c.vars),
});
} catch (error) {
c.log.warn({ msg: "updating the matchmaker failed", error });
}
}
async function claimPendingPlayer(
c: ActorContextOf<typeof ioStyleMatch>,
playerId: string,
): Promise<boolean> {
const client = c.client<typeof registry>();
const result = await client.ioStyleMatchmaker
.getOrCreate(["main"])
.pendingPlayerConnected({ matchId: c.vars.matchId, playerId });
return result.accepted;
}
interface Snapshot {
matchId: string;
capacity: number;
tick: number;
playerCount: number;
worldSize: number;
players: Record<string, { x: number; y: number; color: string }>;
}
function buildSnapshot(c: ActorContextOf<typeof ioStyleMatch>): Snapshot {
const players: Record<string, { x: number; y: number; color: string }> = {};
for (const [id, entry] of Object.entries(c.vars.players)) {
if (entry.disconnectedAt) continue;
players[id] = { x: entry.x, y: entry.y, color: entry.color };
}
return {
matchId: c.vars.matchId,
capacity: CAPACITY,
tick: c.vars.tick,
playerCount: activePlayerCount(c.vars),
worldSize: WORLD_SIZE,
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;
}
function activePlayerCount(vars: MatchVars): number {
let count = 0;
for (const player of Object.values(vars.players)) {
if (!player.disconnectedAt) count++;
}
return count;
}
function occupiedPlayerCount(vars: MatchVars): number {
return Object.keys(vars.players).length;
}
onWakestartsrunMatchas a background task on every start, andonSleepwaits for it to save and exit. Connected players keep the match awake. Once everyone leaves, it sleeps, and the next connection resumes it from the last save.interval(TICK_MS)ticks every 100 ms untilc.aborted. Each tick moves every player bySPEEDin their input direction, keeps them inside the world, and broadcasts onesnapshotof the lobby.- Ticks only change memory. Every
SNAPSHOT_INTERVAL_MS(1 second), and once more when the Actor stops,saveMatchwrites the tick and every claimed player’s position in onec.db.transaction, so a crash loses at most about a second of movement. Inputs are not saved, so restored players stand still until their client sends input again. setInputstores a direction between -1 and 1 on each axis. That is all a client controls, so it can’t move faster thanSPEEDor leave the map, and the server has nothing else to check.- A player who has been disconnected for
DISCONNECT_GRACE_MS(5 seconds) is removed during a tick, and the match tells the matchmaker so the seat opens up. An empty match stays awake for the 30 second idle timeout before it sleeps, so the loop always removes the last players first. - A player sees their own move about one round trip after pressing a key. That is fine at IO game speeds. For instant response, put the movement step in a shared function that the client runs for prediction and the server runs for real.
The example moves dots without collisions. For eating, growing, or Slither-style bodies, keep the movement simple and use a spatial index such as d3-quadtree to find nearby players instead of comparing every pair. Reach for a physics engine like @dimforge/rapier2d only when the shapes get complex. On a large map, send each connection only the players in view with conn.send(...) instead of broadcasting the whole lobby.
Register the Actors
Add both Actors to the registry and start it:
import { setup } from "rivetkit";
import { arenaMatch } from "./arena/match.ts";
import { arenaMatchmaker } from "./arena/matchmaker.ts";
import { battleRoyaleMatch } from "./battle-royale/match.ts";
import { battleRoyaleMatchmaker } from "./battle-royale/matchmaker.ts";
import { idleLeaderboard } from "./idle/leaderboard.ts";
import { idleWorld } from "./idle/world.ts";
import { ioStyleMatch } from "./io-style/match.ts";
import { ioStyleMatchmaker } from "./io-style/matchmaker.ts";
import { openWorldChunk } from "./open-world/chunk.ts";
import { partyMatch } from "./party/match.ts";
import { partyMatchmaker } from "./party/matchmaker.ts";
import { physics2dWorld } from "./physics-2d/world.ts";
import { physics3dWorld } from "./physics-3d/world.ts";
import { rankedLeaderboard } from "./ranked/leaderboard.ts";
import { rankedMatch } from "./ranked/match.ts";
import { rankedMatchmaker } from "./ranked/matchmaker.ts";
import { rankedPlayer } from "./ranked/player.ts";
import { turnBasedMatch } from "./turn-based/match.ts";
import { turnBasedMatchmaker } from "./turn-based/matchmaker.ts";
export {
arenaMatch,
arenaMatchmaker,
battleRoyaleMatch,
battleRoyaleMatchmaker,
idleLeaderboard,
idleWorld,
ioStyleMatch,
ioStyleMatchmaker,
openWorldChunk,
partyMatch,
partyMatchmaker,
physics2dWorld,
physics3dWorld,
rankedLeaderboard,
rankedMatch,
rankedMatchmaker,
rankedPlayer,
turnBasedMatch,
turnBasedMatchmaker,
};
export const registry = setup({
use: {
arenaMatch,
arenaMatchmaker,
battleRoyaleMatch,
battleRoyaleMatchmaker,
idleLeaderboard,
idleWorld,
ioStyleMatchmaker,
ioStyleMatch,
openWorldChunk,
partyMatch,
partyMatchmaker,
physics2dWorld,
physics3dWorld,
rankedLeaderboard,
rankedMatch,
rankedMatchmaker,
rankedPlayer,
turnBasedMatch,
turnBasedMatchmaker,
},
});
The example registers every game type in one registry. An IO game only needs ioStyleMatchmaker and ioStyleMatch. Each Actor file imports type { registry } so that c.client<typeof registry>() is typed when the match calls the matchmaker and the matchmaker creates a match.
Connect players
The menu asks the matchmaker for a lobby, then the game connects to it, sends input, and draws the snapshots:
import { useState } from "react";
import type { GameClient } from "../../client.ts";
export interface IoStyleMatchInfo {
matchId: string;
playerId: string;
}
export function IoStyleMenu({
client,
onReady,
onBack,
}: {
client: GameClient;
onReady: (info: IoStyleMatchInfo) => void;
onBack: () => void;
}) {
const [status, setStatus] = useState<"idle" | "matching" | "error">("idle");
const [error, setError] = useState("");
const findMatch = async () => {
setStatus("matching");
try {
// A plain action call needs no connection, because the player only
// talks to the match from here on.
const lobby = await client.ioStyleMatchmaker
.getOrCreate(["main"])
.findLobby();
onReady(lobby);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setStatus("error");
}
};
return (
<div className="app">
<button className="back-link" onClick={onBack}>
← Back
</button>
<div className="menu-container">
<h2>IO-Style</h2>
<p className="menu-description">
Open lobby matchmaking with server-authoritative movement.
Use WASD or arrow keys to move your player around the world.
</p>
<button
className="btn btn-primary"
onClick={() => void findMatch()}
disabled={status === "matching"}
>
{status === "matching" ? "Finding match..." : "Find Match"}
</button>
{status === "error" && (
<div className="status-box">
<p className="status-error">{error}</p>
</div>
)}
</div>
</div>
);
}
import type { IoStyleMatchConn } from "../../actor-types.ts";
import type { GameClient } from "../../client.ts";
import type { IoStyleMatchInfo } from "./menu.tsx";
const PLAYER_RADIUS = 12;
const LERP_FACTOR = 0.2;
const GRID_SPACING = 50;
export class IoGame {
private stopped = false;
private rafId = 0;
private worldSize = 600;
private targets: Record<string, { x: number; y: number; color: string }> =
{};
private display: Record<string, { x: number; y: number }> = {};
private keys: Record<string, boolean> = {};
private lastIx = 0;
private lastIy = 0;
private botInterval = 0;
private conn: IoStyleMatchConn;
constructor(
private canvas: HTMLCanvasElement | null,
client: GameClient,
private matchInfo: IoStyleMatchInfo,
private options: { bot?: boolean } = {},
) {
this.conn = client.ioStyleMatch
.get([matchInfo.matchId], {
params: {
playerId: matchInfo.playerId,
},
})
.connect();
this.conn.on("snapshot", (snap) => {
this.worldSize = snap.worldSize;
for (const [id, pos] of Object.entries(snap.players)) {
this.targets[id] = pos;
if (!this.display[id])
this.display[id] = { x: pos.x, y: pos.y };
}
for (const id of Object.keys(this.targets)) {
if (!snap.players[id]) {
delete this.targets[id];
delete this.display[id];
}
}
});
if (options.bot) {
this.botInterval = window.setInterval(() => {
const ix = Math.floor(Math.random() * 3) - 1;
const iy = Math.floor(Math.random() * 3) - 1;
this.conn.setInput({ inputX: ix, inputY: iy }).catch(() => {});
}, 500);
} else if (canvas) {
window.addEventListener("keydown", this.onKeyDown);
window.addEventListener("keyup", this.onKeyUp);
this.rafId = requestAnimationFrame(this.draw);
}
}
destroy() {
this.stopped = true;
cancelAnimationFrame(this.rafId);
clearInterval(this.botInterval);
if (!this.options.bot) {
window.removeEventListener("keydown", this.onKeyDown);
window.removeEventListener("keyup", this.onKeyUp);
}
this.conn.dispose().catch(() => {});
}
private onKeyDown = (e: KeyboardEvent) => {
this.keys[e.key] = true;
this.sendInput();
};
private onKeyUp = (e: KeyboardEvent) => {
this.keys[e.key] = false;
this.sendInput();
};
private sendInput() {
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 !== this.lastIx || iy !== this.lastIy) {
this.lastIx = ix;
this.lastIy = iy;
this.conn.setInput({ inputX: ix, inputY: iy }).catch(() => {});
}
}
private draw = () => {
if (this.stopped) return;
const canvas = this.canvas!;
const ctx = canvas.getContext("2d")!;
const sx = canvas.width / this.worldSize;
const sy = canvas.height / this.worldSize;
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();
for (const [id, target] of Object.entries(this.targets)) {
const d = this.display[id];
if (!d) continue;
d.x += (target.x - d.x) * LERP_FACTOR;
d.y += (target.y - d.y) * LERP_FACTOR;
const px = d.x * sx;
const py = d.y * sy;
const isMe = id === this.matchInfo.playerId;
ctx.beginPath();
ctx.arc(px, py, PLAYER_RADIUS, 0, Math.PI * 2);
ctx.fillStyle = target.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.slice(0, 6), px, py - PLAYER_RADIUS - 4);
}
this.rafId = requestAnimationFrame(this.draw);
};
}
findLobbyis an action, so the menu awaits its return value. It calls it on a handle without opening a connection, because the player only talks to the match after that.- The game uses
ioStyleMatch.get(...), notgetOrCreate, because only the matchmaker creates lobbies.playerIdgoes inparams, which is whereonConnectreads it. sendInputruns on key down and key up, and only callssetInputwhen the direction actually changes. Holding a key sends one message, not one per frame.- Each
snapshotupdates the target position of every player. The draw loop moves each dot 20% of the way to its target every frame, which hides the gaps between ticks.
Try it
git clone https://github.com/rivet-dev/rivet.git
cd rivet/examples/multiplayer-game-patterns
pnpm install
pnpm dev
Open http://localhost:5173, pick IO-Style, and click Find Match. Move with WASD or the arrow keys. Open a second tab and find a match again: it joins the same lobby, because that lobby is the fullest one with room. Click Add Bot to fill the map with players that wander around.
Next steps
- Battle Royale: the same lobby flow, with a start countdown and combat.
- Lifecycle: how
onWake,onSleep, andc.aborteddrive a tick loop. - Events: broadcast to everyone or send to one connection.