# IO Game

The game client sends findLobby to the ioStyleMatchmaker Actor, which keeps lobbies and seat reservations in its SQLite tables. If every lobby is full, the matchmaker creates an ioStyleMatch Actor. It replies with a matchId and playerId. The client connects to the match with its playerId, and the match claims the seat by sending pendingPlayerConnected to the matchmaker. The client then sends setInput when its direction changes, and the match broadcasts a snapshot event every tick. The numbers show the order of the calls. There is one matchmaker and one ioStyleMatch Actor per match.

What we'll build:

- An `ioStyleMatchmaker` Actor that sends each player to the fullest lobby with room, and opens a new lobby when every lobby is full.
- An `ioStyleMatch` Actor 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

```sh
npm add rivetkit@next
```

### Create the matchmaker

One `ioStyleMatchmaker` Actor, keyed `["main"]`, decides which lobby each player joins. It keeps lobbies and seat reservations in its SQLite database:

matchmaker.ts:

```ts
/*
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, queue } 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;

export const ioStyleMatchmaker = actor({
	options: { name: "IO - Matchmaker", icon: "earth-americas" },
	db: db({
		onMigrate: migrateTables,
	}),
	queues: {
		findLobby: queue<
			Record<string, never>,
			{ matchId: string; playerId: string }
		>(),
		pendingPlayerConnected: queue<
			{ matchId: string; playerId: string },
			{ accepted: boolean }
		>(),
		updateMatch: queue<{ matchId: string; connectedPlayerCount: number }>(),
		closeMatch: queue<{ matchId: string }>(),
	},
	run: async (c) => {
		for await (const message of c.queue.iter({ completable: true })) {
			const now = Date.now();
			await expirePendingPlayers(c, now);

			if (message.name === "findLobby") {
				const result = await processFindLobby(c, now);
				await message.complete(result);
			} else if (message.name === "pendingPlayerConnected") {
				const result = await processPendingPlayerConnected(
					c,
					message.body,
					now,
				);
				await message.complete(result);
			} else if (message.name === "updateMatch") {
				await c.db.execute(
					`UPDATE matches SET connected_player_count = ?, updated_at = ? WHERE match_id = ?`,
					message.body.connectedPlayerCount,
					now,
					message.body.matchId,
				);
				await syncClaimedPlayerCount(c, message.body.matchId, now);
				await message.complete();
			} else if (message.name === "closeMatch") {
				await c.db.execute(
					`DELETE FROM pending_players WHERE match_id = ?`,
					message.body.matchId,
				);
				await c.db.execute(
					`DELETE FROM matches WHERE match_id = ?`,
					message.body.matchId,
				);
				await message.complete();
			}
		}
	},
});

async function processFindLobby(
	c: ActorContextOf<typeof ioStyleMatchmaker>,
	now: number,
): Promise<{ matchId: string; playerId: string }> {
	const rows = await c.db.execute<{ match_id: string; player_count: number }>(
		`SELECT match_id, player_count FROM matches WHERE player_count < ? ORDER BY player_count DESC, updated_at DESC LIMIT 1`,
		CAPACITY,
	);
	let matchId = rows[0]?.match_id ?? null;

	if (!matchId) {
		matchId = crypto.randomUUID();
		await c.db.execute(
			`INSERT INTO matches (match_id, player_count, connected_player_count, updated_at) VALUES (?, ?, ?, ?)`,
			matchId,
			0,
			0,
			now,
		);
		const client = c.client<typeof registry>();
		await client.ioStyleMatch.create([matchId], {
			input: { matchId },
		});
	}

	const playerId = crypto.randomUUID();
	const expiresAt = now + JOIN_RESERVATION_TTL_MS;

	await c.db.execute(
		`INSERT INTO pending_players (match_id, player_id, expires_at, created_at) VALUES (?, ?, ?, ?)`,
		matchId,
		playerId,
		expiresAt,
		now,
	);
	await syncClaimedPlayerCount(c, matchId, now);

	return { matchId, playerId };
}

async function processPendingPlayerConnected(
	c: ActorContextOf<typeof ioStyleMatchmaker>,
	input: { matchId: string; playerId: string },
	now: number,
): Promise<{ accepted: boolean }> {
	const rows = await c.db.execute<{ expires_at: number }>(
		`SELECT expires_at FROM pending_players WHERE match_id = ? AND player_id = ? LIMIT 1`,
		input.matchId,
		input.playerId,
	);
	const row = rows[0];
	if (!row) {
		return { accepted: false };
	}
	if (row.expires_at <= now) {
		await c.db.execute(
			`DELETE FROM pending_players WHERE match_id = ? AND player_id = ?`,
			input.matchId,
			input.playerId,
		);
		await syncClaimedPlayerCount(c, input.matchId, now);
		return { accepted: false };
	}

	await c.db.execute(
		`DELETE FROM pending_players WHERE match_id = ? AND player_id = ?`,
		input.matchId,
		input.playerId,
	);
	await syncClaimedPlayerCount(c, input.matchId, now);
	return { accepted: true };
}

async function expirePendingPlayers(
	c: ActorContextOf<typeof ioStyleMatchmaker>,
	now: number,
): Promise<void> {
	const rows = await c.db.execute<{ match_id: string }>(
		`SELECT DISTINCT match_id FROM pending_players WHERE expires_at <= ?`,
		now,
	);
	if (rows.length === 0) return;

	await c.db.execute(
		`DELETE FROM pending_players WHERE expires_at <= ?`,
		now,
	);

	for (const row of rows) {
		await syncClaimedPlayerCount(c, row.match_id, now);
	}
}

async function syncClaimedPlayerCount(
	c: ActorContextOf<typeof ioStyleMatchmaker>,
	matchId: string,
	now: number,
): Promise<void> {
	await c.db.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}`,
		);
	}
}
```

- Every request is a queue message, and `run` handles one message at a time. Two players asking at the same moment can't both take the last seat. `findLobby`, `pendingPlayerConnected`, `updateMatch`, and `closeMatch` are all queues.
- `processFindLobby` picks the lobby with the most players that is still below `CAPACITY` (32). There is no "started" flag, because an IO lobby never closes to new players. If every lobby is full, it inserts a row and creates the `ioStyleMatch` Actor with the match id as its input.
- It returns a new `playerId` and saves it in `pending_players` for 15 seconds. `player_count` counts pending players as well as connected ones, so a burst of players can't overfill a lobby before they connect.
- A reservation that is never claimed expires at the start of the next message, in `expirePendingPlayers`, 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:

match.ts:

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

const TICK_MS = 100;
const DISCONNECT_GRACE_MS = 5000;

interface PlayerEntry {
	connId: string | null;
	color: string;
	x: number;
	y: number;
	inputX: number;
	inputY: number;
	disconnectedAt: number | null;
}

interface State {
	matchId: string;
	tick: number;
	players: Record<string, PlayerEntry>;
}

interface ConnParams {
	playerId?: string;
}

export const ioStyleMatch = actor({
	options: { name: "IO - Match", icon: "earth-americas" },
	events: {
		snapshot: event<Snapshot>(),
	},
	createState: (_c, input: { matchId: string }): State => ({
		matchId: input.matchId,
		tick: 0,
		players: {},
	}),
	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.state.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.state.players[playerId] = {
				connId: conn.id,
				color: getPlayerColor(playerId),
				x: Math.random() * WORLD_SIZE,
				y: Math.random() * WORLD_SIZE,
				inputX: 0,
				inputY: 0,
				disconnectedAt: null,
			};
		}

		await updateMatchmaker(c);
		broadcastSnapshot(c);
	},
	onDestroy: async (c) => {
		const client = c.client<typeof registry>();
		await client.ioStyleMatchmaker
			.getOrCreate(["main"])
			.send("closeMatch", {
				matchId: c.state.matchId,
			});
	},
	onDisconnect: async (c, conn) => {
		const found = findPlayerByConnId(c.state, conn.id);
		if (!found) return;
		const [, player] = found;
		player.connId = null;
		player.disconnectedAt = Date.now();

		await updateMatchmaker(c);
		broadcastSnapshot(c);
	},
	run: async (c) => {
		const tick = interval(TICK_MS);
		while (!c.aborted) {
			await tick();
			if (c.aborted) break;
			c.state.tick += 1;

			const now = Date.now();
			let removedPlayer = false;
			for (const [id, player] of Object.entries(c.state.players)) {
				if (
					player.disconnectedAt &&
					now - player.disconnectedAt > DISCONNECT_GRACE_MS
				) {
					delete c.state.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);
		}
	},
	actions: {
		setInput: (c, input: { inputX: number; inputY: number }) => {
			const found = findPlayerByConnId(c.state, 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));
}

async function updateMatchmaker(c: ActorContextOf<typeof ioStyleMatch>) {
	const client = c.client<typeof registry>();
	await client.ioStyleMatchmaker.getOrCreate(["main"]).send("updateMatch", {
		matchId: c.state.matchId,
		connectedPlayerCount: occupiedPlayerCount(c.state),
	});
}

async function claimPendingPlayer(
	c: ActorContextOf<typeof ioStyleMatch>,
	playerId: string,
): Promise<boolean> {
	const client = c.client<typeof registry>();
	const result = await client.ioStyleMatchmaker.getOrCreate(["main"]).send(
		"pendingPlayerConnected",
		{
			matchId: c.state.matchId,
			playerId,
		},
		{ wait: true, timeout: 3_000 },
	);
	if (result.status !== "completed") {
		return false;
	}
	return result.response?.accepted === true;
}

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.state.players)) {
		if (entry.disconnectedAt) continue;
		players[id] = { x: entry.x, y: entry.y, color: entry.color };
	}
	return {
		matchId: c.state.matchId,
		capacity: CAPACITY,
		tick: c.state.tick,
		playerCount: activePlayerCount(c.state),
		worldSize: WORLD_SIZE,
		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;
}

function activePlayerCount(state: State): number {
	let count = 0;
	for (const player of Object.values(state.players)) {
		if (!player.disconnectedAt) count++;
	}
	return count;
}

function occupiedPlayerCount(state: State): number {
	return Object.keys(state.players).length;
}
```

- `onConnect` reads `playerId` from the connection params. A new player has to be claimed from the matchmaker with `pendingPlayerConnected` first. An unknown or expired id is disconnected with `player_not_pending`.
- `claimPendingPlayer` waits for the matchmaker's answer for at most 3 seconds and treats a timeout as a rejection, so a busy matchmaker can't hang a join.
- New players spawn at a random point. A player who reconnects keeps their position, with the new connection id.
- After every join and leave, `updateMatchmaker` sends `updateMatch` with the number of seats in use, so the matchmaker's counts stay correct as players come and go.

> **WARNING:** 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](/guides/authentication).

### Simulate movement on the server

The match moves every player itself. Clients only tell it which way they want to go:

match.ts:

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

const TICK_MS = 100;
const DISCONNECT_GRACE_MS = 5000;

interface PlayerEntry {
	connId: string | null;
	color: string;
	x: number;
	y: number;
	inputX: number;
	inputY: number;
	disconnectedAt: number | null;
}

interface State {
	matchId: string;
	tick: number;
	players: Record<string, PlayerEntry>;
}

interface ConnParams {
	playerId?: string;
}

export const ioStyleMatch = actor({
	options: { name: "IO - Match", icon: "earth-americas" },
	events: {
		snapshot: event<Snapshot>(),
	},
	createState: (_c, input: { matchId: string }): State => ({
		matchId: input.matchId,
		tick: 0,
		players: {},
	}),
	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.state.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.state.players[playerId] = {
				connId: conn.id,
				color: getPlayerColor(playerId),
				x: Math.random() * WORLD_SIZE,
				y: Math.random() * WORLD_SIZE,
				inputX: 0,
				inputY: 0,
				disconnectedAt: null,
			};
		}

		await updateMatchmaker(c);
		broadcastSnapshot(c);
	},
	onDestroy: async (c) => {
		const client = c.client<typeof registry>();
		await client.ioStyleMatchmaker
			.getOrCreate(["main"])
			.send("closeMatch", {
				matchId: c.state.matchId,
			});
	},
	onDisconnect: async (c, conn) => {
		const found = findPlayerByConnId(c.state, conn.id);
		if (!found) return;
		const [, player] = found;
		player.connId = null;
		player.disconnectedAt = Date.now();

		await updateMatchmaker(c);
		broadcastSnapshot(c);
	},
	run: async (c) => {
		const tick = interval(TICK_MS);
		while (!c.aborted) {
			await tick();
			if (c.aborted) break;
			c.state.tick += 1;

			const now = Date.now();
			let removedPlayer = false;
			for (const [id, player] of Object.entries(c.state.players)) {
				if (
					player.disconnectedAt &&
					now - player.disconnectedAt > DISCONNECT_GRACE_MS
				) {
					delete c.state.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);
		}
	},
	actions: {
		setInput: (c, input: { inputX: number; inputY: number }) => {
			const found = findPlayerByConnId(c.state, 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));
}

async function updateMatchmaker(c: ActorContextOf<typeof ioStyleMatch>) {
	const client = c.client<typeof registry>();
	await client.ioStyleMatchmaker.getOrCreate(["main"]).send("updateMatch", {
		matchId: c.state.matchId,
		connectedPlayerCount: occupiedPlayerCount(c.state),
	});
}

async function claimPendingPlayer(
	c: ActorContextOf<typeof ioStyleMatch>,
	playerId: string,
): Promise<boolean> {
	const client = c.client<typeof registry>();
	const result = await client.ioStyleMatchmaker.getOrCreate(["main"]).send(
		"pendingPlayerConnected",
		{
			matchId: c.state.matchId,
			playerId,
		},
		{ wait: true, timeout: 3_000 },
	);
	if (result.status !== "completed") {
		return false;
	}
	return result.response?.accepted === true;
}

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.state.players)) {
		if (entry.disconnectedAt) continue;
		players[id] = { x: entry.x, y: entry.y, color: entry.color };
	}
	return {
		matchId: c.state.matchId,
		capacity: CAPACITY,
		tick: c.state.tick,
		playerCount: activePlayerCount(c.state),
		worldSize: WORLD_SIZE,
		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;
}

function activePlayerCount(state: State): number {
	let count = 0;
	for (const player of Object.values(state.players)) {
		if (!player.disconnectedAt) count++;
	}
	return count;
}

function occupiedPlayerCount(state: State): number {
	return Object.keys(state.players).length;
}
```

- `interval(TICK_MS)` ticks every 100 ms until `c.aborted`. Each tick moves every player by `SPEED` in their input direction, keeps them inside the world, and broadcasts one `snapshot` of the lobby.
- `setInput` stores a direction between -1 and 1 on each axis. That is all a client controls, so it can't move faster than `SPEED` or 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.
- 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.

> **NOTE:** 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:

actors/index.ts:

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

index.ts:

```ts
import { registry } from "./actors/index.ts";

export { registry };

registry.start();
```

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:

menu.tsx:

```tsx
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 {
			const mm = client.ioStyleMatchmaker.getOrCreate(["main"]).connect();
			const result = await mm.send(
				"findLobby",
				{},
				{ wait: true, timeout: 10_000 },
			);
			mm.dispose();
			const response = result.response;
			if (!response?.matchId || !response?.playerId) {
				throw new Error("Matchmaker did not return a valid lobby");
			}
			onReady(response);
		} catch (err) {
			setError(err instanceof Error ? err.message : String(err));
			setStatus("error");
		}
	};

	return (
		<div className="app">
			<button className="back-link" onClick={onBack}>
				&larr; 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>
	);
}
```

io-game.ts:

```ts
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);
	};
}
```

- `mm.send("findLobby", {}, { wait: true })` waits for the queue's reply. The menu disposes the matchmaker connection right after, because the player now talks only to the match.
- The game uses `ioStyleMatch.get(...)`, not `getOrCreate`, because only the matchmaker creates lobbies. `playerId` goes in `params`, which is where `onConnect` reads it.
- `sendInput` runs on key down and key up, and only calls `setInput` when the direction actually changes. Holding a key sends one message, not one per frame.
- Each `snapshot` updates 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

```sh
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](/guides/battle-royale): the same lobby flow, with a start countdown and combat.
- [Lifecycle](/actors/docs/lifecycle): how `run` and `c.aborted` drive a tick loop.
- [Events](/actors/docs/events): broadcast to everyone or send to one connection.
