# Open World Game

Each player's game client connects to the openWorldChunk Actor for the chunk it stands in, keyed by world id and chunk coordinates, and calls enterChunk and setInput on it. The other player does the same on the next chunk. Every chunk runs a tick loop and sends a snapshot event to every connected client, including clients that only watch it from a neighboring chunk. When the player walks off the edge, the client calls enterChunk on the next chunk, then leaveChunk on the old one. The numbers show the order of the calls. There is one openWorldChunk Actor per chunk.

What we'll build:

- An `openWorldChunk` Actor per chunk, keyed by world id and chunk coordinates.
- A 10 tick per second loop in each chunk that moves its players and sends snapshots.
- Blocks that players place and remove, saved in the chunk's state.
- A browser client that watches every chunk in view and moves the player to the next chunk at an edge.

### Install

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

### Create the chunk Actor

Each chunk of the world is its own `openWorldChunk` Actor. A connection can watch a chunk, and becomes a player in it with `enterChunk`:

chunk.ts:

```ts
import { actor, event } from "rivetkit";
import { interval } from "rivetkit/utils";
import { getPlayerColor } from "../player-color.ts";
import { CHUNK_SIZE, SPEED, SPRINT_MULTIPLIER, TICK_MS } from "./config.ts";

const GRID_COLS = Math.floor(CHUNK_SIZE / 50);

interface PlayerEntry {
	connId: string;
	name: string;
	color: string;
	x: number;
	y: number;
	inputX: number;
	inputY: number;
	sprint: boolean;
}

interface ConnectionMeta {
	playerId: string;
	inChunk: boolean;
}

interface State {
	worldId: string;
	chunkX: number;
	chunkY: number;
	tick: number;
	connections: Record<string, ConnectionMeta>;
	players: Record<string, PlayerEntry>;
	blocks: Record<string, string>;
}

// The open world is partitioned into fixed size chunks keyed by world ID and chunk coordinates.
// Clients can keep multiple chunk connections open to observe nearby state.
// Each connection carries a stable player ID while explicit enter/leave actions control chunk membership.
export const openWorldChunk = actor({
	options: { name: "Open World - Chunk", icon: "map" },
	events: {
		snapshot: event<Snapshot>(),
	},
	createState: (c): State => {
		const key = Array.isArray(c.key) ? c.key : [c.key];
		const chunkX = Number(key[1] ?? "0");
		const chunkY = Number(key[2] ?? "0");
		return {
			worldId: key[0] ?? "",
			chunkX: Number.isFinite(chunkX) ? chunkX : 0,
			chunkY: Number.isFinite(chunkY) ? chunkY : 0,
			tick: 0,
			connections: {},
			players: {},
			blocks: {},
		};
	},
	onConnect: (c, conn) => {
		if (!c.state.connections) c.state.connections = {};
		const playerId = parsePlayerId(conn.params) ?? conn.id;
		c.state.connections[conn.id] = {
			playerId,
			inChunk: false,
		};
		broadcastSnapshot(c);
	},
	onDisconnect: (c, conn) => {
		const meta = c.state.connections?.[conn.id];
		if (!meta) return;
		delete c.state.connections[conn.id];

		const player = c.state.players[meta.playerId];
		if (player?.connId === conn.id) {
			delete c.state.players[meta.playerId];
		}
		broadcastSnapshot(c);
	},
	run: async (c) => {
		if (Array.isArray(c.state.blocks)) {
			const migratedBlocks: Record<string, string> = {};
			for (const blockKey of c.state.blocks) {
				migratedBlocks[blockKey] = "#ff4f00";
			}
			c.state.blocks = migratedBlocks;
		}
		if (!c.state.blocks) c.state.blocks = {};
		if (!c.state.connections) c.state.connections = {};
		const tick = interval(TICK_MS);
		while (!c.aborted) {
			await tick();
			if (c.aborted) break;
			c.state.tick += 1;

			for (const player of Object.values(c.state.players)) {
				const speed = player.sprint ? SPEED * SPRINT_MULTIPLIER : SPEED;
				player.x += speed * player.inputX;
				player.y += speed * player.inputY;

				player.x = Math.max(0, Math.min(CHUNK_SIZE - 1, player.x));
				player.y = Math.max(0, Math.min(CHUNK_SIZE - 1, player.y));
			}

			broadcastSnapshot(c);
		}
	},
	actions: {
		enterChunk: (
			c,
			input: { name: string; spawnX?: number; spawnY?: number },
		) => {
			const meta = getConnectionMeta(c, c.conn.id);
			if (!meta) return;

			const nameRaw = input.name.trim();
			const name = nameRaw ? nameRaw.slice(0, 24) : "Player";
			const existing = c.state.players[meta.playerId];
			c.state.players[meta.playerId] = {
				connId: c.conn.id,
				name,
				color: existing?.color ?? getPlayerColor(meta.playerId),
				x: clampToChunk(input.spawnX) ?? CHUNK_SIZE / 2,
				y: clampToChunk(input.spawnY) ?? CHUNK_SIZE / 2,
				inputX: 0,
				inputY: 0,
				sprint: false,
			};
			meta.inChunk = true;
			broadcastSnapshot(c);
		},
		addPlayer: (
			c,
			input: { name: string; spawnX?: number; spawnY?: number },
		) => {
			const meta = getConnectionMeta(c, c.conn.id);
			if (!meta) return;

			const nameRaw = input.name.trim();
			const name = nameRaw ? nameRaw.slice(0, 24) : "Player";
			const existing = c.state.players[meta.playerId];
			c.state.players[meta.playerId] = {
				connId: c.conn.id,
				name,
				color: existing?.color ?? getPlayerColor(meta.playerId),
				x: clampToChunk(input.spawnX) ?? CHUNK_SIZE / 2,
				y: clampToChunk(input.spawnY) ?? CHUNK_SIZE / 2,
				inputX: 0,
				inputY: 0,
				sprint: false,
			};
			meta.inChunk = true;
			broadcastSnapshot(c);
		},
		leaveChunk: (c) => {
			const meta = getConnectionMeta(c, c.conn.id);
			if (!meta) return;

			meta.inChunk = false;
			const player = c.state.players[meta.playerId];
			if (player?.connId === c.conn.id) {
				delete c.state.players[meta.playerId];
			}
			broadcastSnapshot(c);
		},
		removePlayer: (c) => {
			const meta = getConnectionMeta(c, c.conn.id);
			if (!meta) return;

			meta.inChunk = false;
			const player = c.state.players[meta.playerId];
			if (player?.connId === c.conn.id) {
				delete c.state.players[meta.playerId];
			}
			broadcastSnapshot(c);
		},
		setInput: (
			c,
			input: { inputX: number; inputY: number; sprint?: boolean },
		) => {
			const player = getControlledPlayer(c, c.conn.id);
			if (!player) return;
			player.inputX = Math.max(-1, Math.min(1, input.inputX));
			player.inputY = Math.max(-1, Math.min(1, input.inputY));
			player.sprint = !!input.sprint;
		},
		placeBlock: (c, input: { gridX: number; gridY: number }) => {
			const player = getControlledPlayer(c, c.conn.id);
			if (!player) return;
			if (Array.isArray(c.state.blocks)) c.state.blocks = {};
			if (!c.state.blocks) c.state.blocks = {};
			const { gridX, gridY } = input;
			if (
				gridX < 0 ||
				gridX >= GRID_COLS ||
				gridY < 0 ||
				gridY >= GRID_COLS
			)
				return;
			const key = `${gridX},${gridY}`;
			if (c.state.blocks[key] !== player.color) {
				c.state.blocks[key] = player.color;
				broadcastSnapshot(c);
			}
		},
		removeBlock: (c, input: { gridX: number; gridY: number }) => {
			if (!getConnectionMeta(c, c.conn.id)) return;
			if (Array.isArray(c.state.blocks)) c.state.blocks = {};
			if (!c.state.blocks) c.state.blocks = {};
			const key = `${input.gridX},${input.gridY}`;
			if (key in c.state.blocks) {
				delete c.state.blocks[key];
				broadcastSnapshot(c);
			}
		},
		getSnapshot: (c) => buildSnapshot(c, null),
	},
});

interface Snapshot {
	worldId: string;
	chunkX: number;
	chunkY: number;
	chunkSize: number;
	tick: number;
	selfPlayerId: string | null;
	players: Record<
		string,
		{ x: number; y: number; name: string; color: string }
	>;
	blocks: Record<string, string>;
}

function buildSnapshot(c: { state: State }, playerId: string | null): Snapshot {
	const players: Snapshot["players"] = {};
	for (const [id, player] of Object.entries(c.state.players)) {
		players[id] = {
			x: player.x,
			y: player.y,
			name: player.name,
			color: player.color,
		};
	}
	return {
		worldId: c.state.worldId,
		chunkX: c.state.chunkX,
		chunkY: c.state.chunkY,
		chunkSize: CHUNK_SIZE,
		tick: c.state.tick,
		selfPlayerId: playerId,
		players,
		blocks: Array.isArray(c.state.blocks)
			? Object.fromEntries(
					c.state.blocks.map((blockKey) => [blockKey, "#ff4f00"]),
				)
			: { ...c.state.blocks },
	};
}

function broadcastSnapshot(c: {
	state: State;
	conns: Map<
		string,
		{
			id: string;
			send: (eventName: "snapshot", data: Snapshot) => void;
		}
	>;
}) {
	for (const conn of c.conns.values()) {
		const meta = c.state.connections?.[conn.id];
		const player = meta ? c.state.players[meta.playerId] : undefined;
		const selfPlayerId =
			meta?.inChunk && player?.connId === conn.id ? meta.playerId : null;
		try {
			conn.send("snapshot", buildSnapshot(c, selfPlayerId));
		} catch {
			// Skip connections that are not fully established yet.
		}
	}
}

function clampToChunk(raw: number | undefined): number | null {
	if (raw === undefined) return null;
	const value = Number(raw);
	if (!Number.isFinite(value)) return null;
	return Math.max(0, Math.min(CHUNK_SIZE - 1, value));
}

function parsePlayerId(params: unknown): string | null {
	const playerId = (params as { playerId?: string } | null)?.playerId;
	if (typeof playerId !== "string") return null;
	const trimmed = playerId.trim();
	return trimmed ? trimmed.slice(0, 64) : null;
}

function getConnectionMeta(
	c: { state: State },
	connId: string,
): ConnectionMeta | null {
	return c.state.connections?.[connId] ?? null;
}

function getControlledPlayer(
	c: { state: State },
	connId: string,
): PlayerEntry | null {
	const meta = getConnectionMeta(c, connId);
	if (!meta?.inChunk) return null;
	const player = c.state.players[meta.playerId];
	if (!player || player.connId !== connId) return null;
	return player;
}
```

config.ts:

```ts
export const CHUNK_SIZE = 1200;
export const TICK_MS = 100; // 10 tps
export const SPEED = 16;
export const SPRINT_MULTIPLIER = 2.5;
export const WORLD_ID = "default";
```

- The key is `[worldId, chunkX, chunkY]`. `createState` reads the coordinates from `c.key`, so a chunk knows where it is without any input. There is no matchmaker: whoever knows a position can compute its chunk key.
- A connection is not a player. `onConnect` records the `playerId` from the connection params with `inChunk: false`, so a client can watch a chunk without standing in it. `enterChunk` adds the player at a spawn point clamped to the chunk, and `leaveChunk` removes it. `addPlayer` and `removePlayer` do the same thing.
- `setInput` only accepts input from the connection that controls the player in this chunk, and clamps each axis to -1 to 1. The client sends a direction, never a position.
- Positions are local to the chunk, from 0 to `CHUNK_SIZE` (1200). The world position is `chunkX * CHUNK_SIZE + x`.

> **WARNING:** The client chooses its `playerId` and its spawn point, so it can claim another player's id or enter any chunk at any spot. In production, issue the player id from your [authentication](/guides/authentication), and have the old chunk hand the player to the next one instead of trusting the client's position.

### Run the tick loop

Each chunk simulates only its own players, in a loop in `run`:

chunk.ts:

```ts
import { actor, event } from "rivetkit";
import { interval } from "rivetkit/utils";
import { getPlayerColor } from "../player-color.ts";
import { CHUNK_SIZE, SPEED, SPRINT_MULTIPLIER, TICK_MS } from "./config.ts";

const GRID_COLS = Math.floor(CHUNK_SIZE / 50);

interface PlayerEntry {
	connId: string;
	name: string;
	color: string;
	x: number;
	y: number;
	inputX: number;
	inputY: number;
	sprint: boolean;
}

interface ConnectionMeta {
	playerId: string;
	inChunk: boolean;
}

interface State {
	worldId: string;
	chunkX: number;
	chunkY: number;
	tick: number;
	connections: Record<string, ConnectionMeta>;
	players: Record<string, PlayerEntry>;
	blocks: Record<string, string>;
}

// The open world is partitioned into fixed size chunks keyed by world ID and chunk coordinates.
// Clients can keep multiple chunk connections open to observe nearby state.
// Each connection carries a stable player ID while explicit enter/leave actions control chunk membership.
export const openWorldChunk = actor({
	options: { name: "Open World - Chunk", icon: "map" },
	events: {
		snapshot: event<Snapshot>(),
	},
	createState: (c): State => {
		const key = Array.isArray(c.key) ? c.key : [c.key];
		const chunkX = Number(key[1] ?? "0");
		const chunkY = Number(key[2] ?? "0");
		return {
			worldId: key[0] ?? "",
			chunkX: Number.isFinite(chunkX) ? chunkX : 0,
			chunkY: Number.isFinite(chunkY) ? chunkY : 0,
			tick: 0,
			connections: {},
			players: {},
			blocks: {},
		};
	},
	onConnect: (c, conn) => {
		if (!c.state.connections) c.state.connections = {};
		const playerId = parsePlayerId(conn.params) ?? conn.id;
		c.state.connections[conn.id] = {
			playerId,
			inChunk: false,
		};
		broadcastSnapshot(c);
	},
	onDisconnect: (c, conn) => {
		const meta = c.state.connections?.[conn.id];
		if (!meta) return;
		delete c.state.connections[conn.id];

		const player = c.state.players[meta.playerId];
		if (player?.connId === conn.id) {
			delete c.state.players[meta.playerId];
		}
		broadcastSnapshot(c);
	},
	run: async (c) => {
		if (Array.isArray(c.state.blocks)) {
			const migratedBlocks: Record<string, string> = {};
			for (const blockKey of c.state.blocks) {
				migratedBlocks[blockKey] = "#ff4f00";
			}
			c.state.blocks = migratedBlocks;
		}
		if (!c.state.blocks) c.state.blocks = {};
		if (!c.state.connections) c.state.connections = {};
		const tick = interval(TICK_MS);
		while (!c.aborted) {
			await tick();
			if (c.aborted) break;
			c.state.tick += 1;

			for (const player of Object.values(c.state.players)) {
				const speed = player.sprint ? SPEED * SPRINT_MULTIPLIER : SPEED;
				player.x += speed * player.inputX;
				player.y += speed * player.inputY;

				player.x = Math.max(0, Math.min(CHUNK_SIZE - 1, player.x));
				player.y = Math.max(0, Math.min(CHUNK_SIZE - 1, player.y));
			}

			broadcastSnapshot(c);
		}
	},
	actions: {
		enterChunk: (
			c,
			input: { name: string; spawnX?: number; spawnY?: number },
		) => {
			const meta = getConnectionMeta(c, c.conn.id);
			if (!meta) return;

			const nameRaw = input.name.trim();
			const name = nameRaw ? nameRaw.slice(0, 24) : "Player";
			const existing = c.state.players[meta.playerId];
			c.state.players[meta.playerId] = {
				connId: c.conn.id,
				name,
				color: existing?.color ?? getPlayerColor(meta.playerId),
				x: clampToChunk(input.spawnX) ?? CHUNK_SIZE / 2,
				y: clampToChunk(input.spawnY) ?? CHUNK_SIZE / 2,
				inputX: 0,
				inputY: 0,
				sprint: false,
			};
			meta.inChunk = true;
			broadcastSnapshot(c);
		},
		addPlayer: (
			c,
			input: { name: string; spawnX?: number; spawnY?: number },
		) => {
			const meta = getConnectionMeta(c, c.conn.id);
			if (!meta) return;

			const nameRaw = input.name.trim();
			const name = nameRaw ? nameRaw.slice(0, 24) : "Player";
			const existing = c.state.players[meta.playerId];
			c.state.players[meta.playerId] = {
				connId: c.conn.id,
				name,
				color: existing?.color ?? getPlayerColor(meta.playerId),
				x: clampToChunk(input.spawnX) ?? CHUNK_SIZE / 2,
				y: clampToChunk(input.spawnY) ?? CHUNK_SIZE / 2,
				inputX: 0,
				inputY: 0,
				sprint: false,
			};
			meta.inChunk = true;
			broadcastSnapshot(c);
		},
		leaveChunk: (c) => {
			const meta = getConnectionMeta(c, c.conn.id);
			if (!meta) return;

			meta.inChunk = false;
			const player = c.state.players[meta.playerId];
			if (player?.connId === c.conn.id) {
				delete c.state.players[meta.playerId];
			}
			broadcastSnapshot(c);
		},
		removePlayer: (c) => {
			const meta = getConnectionMeta(c, c.conn.id);
			if (!meta) return;

			meta.inChunk = false;
			const player = c.state.players[meta.playerId];
			if (player?.connId === c.conn.id) {
				delete c.state.players[meta.playerId];
			}
			broadcastSnapshot(c);
		},
		setInput: (
			c,
			input: { inputX: number; inputY: number; sprint?: boolean },
		) => {
			const player = getControlledPlayer(c, c.conn.id);
			if (!player) return;
			player.inputX = Math.max(-1, Math.min(1, input.inputX));
			player.inputY = Math.max(-1, Math.min(1, input.inputY));
			player.sprint = !!input.sprint;
		},
		placeBlock: (c, input: { gridX: number; gridY: number }) => {
			const player = getControlledPlayer(c, c.conn.id);
			if (!player) return;
			if (Array.isArray(c.state.blocks)) c.state.blocks = {};
			if (!c.state.blocks) c.state.blocks = {};
			const { gridX, gridY } = input;
			if (
				gridX < 0 ||
				gridX >= GRID_COLS ||
				gridY < 0 ||
				gridY >= GRID_COLS
			)
				return;
			const key = `${gridX},${gridY}`;
			if (c.state.blocks[key] !== player.color) {
				c.state.blocks[key] = player.color;
				broadcastSnapshot(c);
			}
		},
		removeBlock: (c, input: { gridX: number; gridY: number }) => {
			if (!getConnectionMeta(c, c.conn.id)) return;
			if (Array.isArray(c.state.blocks)) c.state.blocks = {};
			if (!c.state.blocks) c.state.blocks = {};
			const key = `${input.gridX},${input.gridY}`;
			if (key in c.state.blocks) {
				delete c.state.blocks[key];
				broadcastSnapshot(c);
			}
		},
		getSnapshot: (c) => buildSnapshot(c, null),
	},
});

interface Snapshot {
	worldId: string;
	chunkX: number;
	chunkY: number;
	chunkSize: number;
	tick: number;
	selfPlayerId: string | null;
	players: Record<
		string,
		{ x: number; y: number; name: string; color: string }
	>;
	blocks: Record<string, string>;
}

function buildSnapshot(c: { state: State }, playerId: string | null): Snapshot {
	const players: Snapshot["players"] = {};
	for (const [id, player] of Object.entries(c.state.players)) {
		players[id] = {
			x: player.x,
			y: player.y,
			name: player.name,
			color: player.color,
		};
	}
	return {
		worldId: c.state.worldId,
		chunkX: c.state.chunkX,
		chunkY: c.state.chunkY,
		chunkSize: CHUNK_SIZE,
		tick: c.state.tick,
		selfPlayerId: playerId,
		players,
		blocks: Array.isArray(c.state.blocks)
			? Object.fromEntries(
					c.state.blocks.map((blockKey) => [blockKey, "#ff4f00"]),
				)
			: { ...c.state.blocks },
	};
}

function broadcastSnapshot(c: {
	state: State;
	conns: Map<
		string,
		{
			id: string;
			send: (eventName: "snapshot", data: Snapshot) => void;
		}
	>;
}) {
	for (const conn of c.conns.values()) {
		const meta = c.state.connections?.[conn.id];
		const player = meta ? c.state.players[meta.playerId] : undefined;
		const selfPlayerId =
			meta?.inChunk && player?.connId === conn.id ? meta.playerId : null;
		try {
			conn.send("snapshot", buildSnapshot(c, selfPlayerId));
		} catch {
			// Skip connections that are not fully established yet.
		}
	}
}

function clampToChunk(raw: number | undefined): number | null {
	if (raw === undefined) return null;
	const value = Number(raw);
	if (!Number.isFinite(value)) return null;
	return Math.max(0, Math.min(CHUNK_SIZE - 1, value));
}

function parsePlayerId(params: unknown): string | null {
	const playerId = (params as { playerId?: string } | null)?.playerId;
	if (typeof playerId !== "string") return null;
	const trimmed = playerId.trim();
	return trimmed ? trimmed.slice(0, 64) : null;
}

function getConnectionMeta(
	c: { state: State },
	connId: string,
): ConnectionMeta | null {
	return c.state.connections?.[connId] ?? null;
}

function getControlledPlayer(
	c: { state: State },
	connId: string,
): PlayerEntry | null {
	const meta = getConnectionMeta(c, connId);
	if (!meta?.inChunk) return null;
	const player = c.state.players[meta.playerId];
	if (!player || player.connId !== connId) return null;
	return player;
}
```

- `run` starts when the Actor wakes and waits on `interval(TICK_MS)` until `c.aborted` is set. Every 100 ms it moves each player by `SPEED`, or 2.5 times that while sprinting, and clamps them to the chunk edges.
- Load grows with the number of chunks in use, not the size of the world. A chunk nobody has visited has no Actor.
- `broadcastSnapshot` loops over `c.conns` and calls `conn.send` instead of `c.broadcast`, because each snapshot sets `selfPlayerId` only for the connection that controls that player.
- Every snapshot holds the whole chunk: all players and all blocks. That is fine for a small chunk. For busy chunks, send a full snapshot when a client connects and only the changes after that.

> **NOTE:** The server owns movement and clients smooth toward the positions it sends. A sandbox game can let the client move the player and send its position instead, as long as the chunk checks each update: cap the distance moved per update by elapsed time, reject teleports, keep the player inside the chunk, and limit how often a connection can send updates.

### Save blocks in the chunk

Players build on a 24 by 24 grid in each chunk:

chunk.ts:

```ts
placeBlock: (c, input: { gridX: number; gridY: number }) => {
	const player = getControlledPlayer(c, c.conn.id);
	if (!player) return;
	if (Array.isArray(c.state.blocks)) c.state.blocks = {};
	if (!c.state.blocks) c.state.blocks = {};
	const { gridX, gridY } = input;
	if (
		gridX < 0 ||
		gridX >= GRID_COLS ||
		gridY < 0 ||
		gridY >= GRID_COLS
	)
		return;
	const key = `${gridX},${gridY}`;
	if (c.state.blocks[key] !== player.color) {
		c.state.blocks[key] = player.color;
		broadcastSnapshot(c);
	}
},
removeBlock: (c, input: { gridX: number; gridY: number }) => {
	if (!getConnectionMeta(c, c.conn.id)) return;
	if (Array.isArray(c.state.blocks)) c.state.blocks = {};
	if (!c.state.blocks) c.state.blocks = {};
	const key = `${input.gridX},${input.gridY}`;
	if (key in c.state.blocks) {
		delete c.state.blocks[key];
		broadcastSnapshot(c);
	}
},
```

- Blocks live in `c.state.blocks`, which maps `"gridX,gridY"` to the color of the player who placed the block. Actor state is saved, so a chunk keeps its blocks when it sleeps and wakes. Players are not kept: `onDisconnect` removes them.
- `placeBlock` only works for a player standing in this chunk and rejects cells outside the grid. `removeBlock` only checks that the caller is connected, so a client watching from the next chunk can remove blocks. Check it the same way as `placeBlock` if you don't want that.
- Both actions send a snapshot only when a block actually changed.

> **NOTE:** JSON state works for a small grid per chunk. For bigger worlds, with many blocks, inventories, or anything you need to query, store it in the chunk's [SQLite](/actors/docs/sqlite) database and index it by position.

### Register the Actors

Add the chunk Actor 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();
```

- `openWorldChunk` is the only Actor this game needs.
- The example registers every game type in one registry. In your own project, `setup({ use: { openWorldChunk } })` is enough.
- `registry.start()` serves the Actors. The example's browser client connects to `http://localhost:6420`.

### Connect players

The client opens one connection per chunk in view, with the same `playerId` on each:

open-world-game.ts:

```ts
import { CHUNK_SIZE, WORLD_ID } from "../../../src/actors/open-world/config.ts";
import { getPlayerColor } from "../../../src/actors/player-color.ts";
import type { OpenWorldChunkConn } from "../../actor-types.ts";
import type { GameClient } from "../../client.ts";
import type { OpenWorldMatchInfo } from "./menu.tsx";

const PLAYER_RADIUS = 12;
const LERP_FACTOR = 0.2;
const GRID_SPACING = 50;
const BLOCK_SIZE = 50;
const DEFAULT_VIEWPORT_SIZE = 600;
const MIN_ZOOM = 0.25;
const MAX_ZOOM = 2;
const ZOOM_STEP = 0.15;

interface ChunkConnection {
	cx: number;
	cy: number;
	conn: OpenWorldChunkConn;
	players: Record<
		string,
		{ x: number; y: number; name: string; color: string }
	>;
	display: Record<string, { x: number; y: number }>;
	blocks: Map<string, string>;
	selfPresent: boolean;
}

interface ChunkSnapshot {
	chunkSize: number;
	chunkX: number;
	chunkY: number;
	selfPlayerId: string | null;
	players: Record<
		string,
		{ x: number; y: number; name: string; color: string }
	>;
	blocks?: Record<string, string> | string[];
}

export class OpenWorldGame {
	private stopped = false;
	private rafId = 0;
	private chunkSize = CHUNK_SIZE;
	private chunkX: number;
	private chunkY: number;
	private readonly playerId = createPlayerId();
	private playerName: string;
	private keys: Record<string, boolean> = {};
	private lastIx = 0;
	private lastIy = 0;
	private lastSprint = false;
	private transferring = false;
	private mouseCanvasX = -1;
	private mouseCanvasY = -1;
	private lastChunkUpdate = 0;
	private botInterval = 0;
	private zoom = 1;
	private chunks = new Map<string, ChunkConnection>();

	constructor(
		private canvas: HTMLCanvasElement | null,
		private client: GameClient,
		matchInfo: OpenWorldMatchInfo,
		private options: { bot?: boolean } = {},
	) {
		this.chunkX = matchInfo.chunkKey[1];
		this.chunkY = matchInfo.chunkKey[2];
		this.playerName = matchInfo.playerName;

		this.connectToChunk(matchInfo.chunkKey[1], matchInfo.chunkKey[2]);
		void this.setPrimaryChunk(
			matchInfo.chunkKey[1],
			matchInfo.chunkKey[2],
			matchInfo.spawnX,
			matchInfo.spawnY,
		);

		if (options.bot) {
			this.botInterval = window.setInterval(() => {
				const ix = Math.floor(Math.random() * 3) - 1;
				const iy = Math.floor(Math.random() * 3) - 1;
				this.lastIx = ix;
				this.lastIy = iy;
				const primary = this.chunks.get(
					chunkKey(this.chunkX, this.chunkY),
				);
				primary?.conn
					.setInput({ inputX: ix, inputY: iy })
					.catch(() => {});
			}, 500);
		} else if (canvas) {
			window.addEventListener("keydown", this.onKeyDown);
			window.addEventListener("keyup", this.onKeyUp);
			canvas.addEventListener("mousedown", this.onMouseDown);
			canvas.addEventListener("mousemove", this.onMouseMove);
			canvas.addEventListener("mouseleave", this.onMouseLeave);
			canvas.addEventListener("contextmenu", this.onContextMenu);
			this.rafId = requestAnimationFrame(this.draw);
		}
	}

	private connectToChunk(cx: number, cy: number): ChunkConnection {
		const key = chunkKey(cx, cy);
		const existing = this.chunks.get(key);
		if (existing) return existing;

		const conn = this.client.openWorldChunk
			.getOrCreate([WORLD_ID, String(cx), String(cy)], {
				params: { playerId: this.playerId },
			})
			.connect();

		const chunk: ChunkConnection = {
			cx,
			cy,
			conn,
			players: {},
			display: {},
			blocks: new Map(),
			selfPresent: false,
		};

		conn.on("snapshot", (snap) => {
			this.chunkSize = snap.chunkSize;
			const isPrimaryChunk =
				chunk.cx === this.chunkX && chunk.cy === this.chunkY;
			chunk.selfPresent = snap.selfPlayerId === this.playerId;
			for (const [id, pos] of Object.entries(snap.players)) {
				chunk.players[id] = pos;
				if (!chunk.display[id])
					chunk.display[id] = { x: pos.x, y: pos.y };
			}
			for (const id of Object.keys(chunk.players)) {
				if (!snap.players[id]) {
					delete chunk.players[id];
					delete chunk.display[id];
				}
			}
			if (snap.blocks) {
				if (Array.isArray(snap.blocks)) {
					chunk.blocks = new Map(
						snap.blocks.map((blockKey) => [blockKey, "#ff4f00"]),
					);
				} else {
					chunk.blocks = new Map(Object.entries(snap.blocks));
				}
			}

			// Client-driven chunk transfer detection (only for primary chunk).
			if (isPrimaryChunk) {
				const me = snap.players[this.playerId];
				if (me && !this.transferring) {
					const atLeft = me.x <= 0 && this.lastIx < 0;
					const atRight =
						me.x >= this.chunkSize - 1 && this.lastIx > 0;
					const atTop = me.y <= 0 && this.lastIy < 0;
					const atBottom =
						me.y >= this.chunkSize - 1 && this.lastIy > 0;
					if (atLeft || atRight || atTop || atBottom) {
						this.initiateTransfer(me.x, me.y);
					}
				}
			}
		});

		this.chunks.set(key, chunk);
		return chunk;
	}

	private async setPrimaryChunk(
		cx: number,
		cy: number,
		spawnX: number,
		spawnY: number,
	) {
		const previousChunkX = this.chunkX;
		const previousChunkY = this.chunkY;
		const oldPrimaryKey = chunkKey(previousChunkX, previousChunkY);
		const newPrimaryKey = chunkKey(cx, cy);
		const oldPrimary = this.chunks.get(oldPrimaryKey);

		const newPrimary = this.connectToChunk(cx, cy);

		await newPrimary.conn
			.enterChunk({
				name: this.playerName,
				spawnX,
				spawnY,
			})
			.catch(() => {});

		// Keep the old primary active until the target chunk confirms this connection's presence.
		const ready = await this.waitForSelfInChunk(cx, cy);
		if (!ready || this.stopped) {
			if (oldPrimaryKey !== newPrimaryKey) {
				newPrimary.conn.leaveChunk().catch(() => {});
			}
			return;
		}

		this.chunkX = cx;
		this.chunkY = cy;
		this.updateAdjacentChunks();

		if (oldPrimary && oldPrimaryKey !== newPrimaryKey) {
			oldPrimary.conn.leaveChunk().catch(() => {});
		}

		newPrimary.conn
			.setInput({
				inputX: this.lastIx,
				inputY: this.lastIy,
				sprint: this.lastSprint,
			})
			.catch(() => {});
	}

	private async waitForSelfInChunk(cx: number, cy: number): Promise<boolean> {
		const key = chunkKey(cx, cy);
		const deadline = Date.now() + 1500;
		while (!this.stopped && Date.now() < deadline) {
			const chunk = this.chunks.get(key);
			if (chunk?.selfPresent) return true;
			await sleep(16);
		}
		return false;
	}

	/** Compute which chunk indices are visible based on player position and zoom. */
	private getVisibleChunkRange(): {
		minCx: number;
		maxCx: number;
		minCy: number;
		maxCy: number;
	} {
		const primaryChunk = this.chunks.get(
			chunkKey(this.chunkX, this.chunkY),
		);
		const me = primaryChunk?.display[this.playerId];
		const localX = me?.x ?? this.chunkSize / 2;
		const localY = me?.y ?? this.chunkSize / 2;
		const worldX = this.chunkX * this.chunkSize + localX;
		const worldY = this.chunkY * this.chunkSize + localY;

		const viewportSize = DEFAULT_VIEWPORT_SIZE / this.zoom;
		const halfView = viewportSize / 2;

		return {
			minCx: Math.floor((worldX - halfView) / this.chunkSize),
			maxCx: Math.floor((worldX + halfView) / this.chunkSize),
			minCy: Math.floor((worldY - halfView) / this.chunkSize),
			maxCy: Math.floor((worldY + halfView) / this.chunkSize),
		};
	}

	private updateAdjacentChunks() {
		const { minCx, maxCx, minCy, maxCy } = this.getVisibleChunkRange();

		const needed = new Set<string>();
		for (let cx = minCx; cx <= maxCx; cx++) {
			for (let cy = minCy; cy <= maxCy; cy++) {
				needed.add(chunkKey(cx, cy));
			}
		}

		// Disconnect chunks that are no longer needed.
		for (const [key, chunk] of this.chunks) {
			if (
				!needed.has(key) &&
				!(chunk.cx === this.chunkX && chunk.cy === this.chunkY)
			) {
				chunk.conn.dispose().catch(() => {});
				this.chunks.delete(key);
			}
		}

		// Connect to new chunks as observer (read-only).
		for (const key of needed) {
			if (!this.chunks.has(key)) {
				const [cx, cy] = parseChunkKey(key);
				this.connectToChunk(cx, cy);
			}
		}
	}

	private async initiateTransfer(localX: number, localY: number) {
		this.transferring = true;
		try {
			let absX = this.chunkX * this.chunkSize + localX;
			let absY = this.chunkY * this.chunkSize + localY;
			if (this.lastIx < 0) absX -= 1;
			else if (this.lastIx > 0) absX += 1;
			if (this.lastIy < 0) absY -= 1;
			else if (this.lastIy > 0) absY += 1;
			const response = resolveChunkForPosition(absX, absY);
			if (this.stopped) return;
			await this.setPrimaryChunk(
				response.chunkKey[1],
				response.chunkKey[2],
				response.spawnX,
				response.spawnY,
			);
		} catch {
			// Transfer failed, stay in current chunk.
		} finally {
			this.transferring = false;
			// Flush the latest key state after transfer so key-up events that happened
			// during transfer do not leave stale movement latched on the new chunk.
			this.sendInput();
		}
	}

	destroy() {
		this.stopped = true;
		cancelAnimationFrame(this.rafId);
		clearInterval(this.botInterval);
		if (!this.options.bot && this.canvas) {
			window.removeEventListener("keydown", this.onKeyDown);
			window.removeEventListener("keyup", this.onKeyUp);
			this.canvas.removeEventListener("mousedown", this.onMouseDown);
			this.canvas.removeEventListener("mousemove", this.onMouseMove);
			this.canvas.removeEventListener("mouseleave", this.onMouseLeave);
			this.canvas.removeEventListener("contextmenu", this.onContextMenu);
		}
		for (const chunk of this.chunks.values()) {
			chunk.conn.dispose().catch(() => {});
		}
		this.chunks.clear();
	}

	private setKey(key: string, value: boolean) {
		this.keys[key] = value;
		// Normalize single characters to lowercase so Shift doesn't leave stuck keys.
		if (key.length === 1) this.keys[key.toLowerCase()] = value;
	}

	private onKeyDown = (e: KeyboardEvent) => {
		// Zoom controls.
		if (e.key === "=" || e.key === "+") {
			this.zoom = Math.min(MAX_ZOOM, this.zoom + ZOOM_STEP);
			this.updateAdjacentChunks();
			return;
		}
		if (e.key === "-" || e.key === "_") {
			this.zoom = Math.max(MIN_ZOOM, this.zoom - ZOOM_STEP);
			this.updateAdjacentChunks();
			return;
		}
		this.setKey(e.key, true);
		this.sendInput();
	};

	private onKeyUp = (e: KeyboardEvent) => {
		this.setKey(e.key, false);
		this.sendInput();
	};

	private onContextMenu = (e: MouseEvent) => {
		e.preventDefault();
	};

	private onMouseMove = (e: MouseEvent) => {
		if (!this.canvas) return;
		const rect = this.canvas.getBoundingClientRect();
		this.mouseCanvasX = e.clientX - rect.left;
		this.mouseCanvasY = e.clientY - rect.top;
	};

	private onMouseLeave = () => {
		this.mouseCanvasX = -1;
		this.mouseCanvasY = -1;
	};

	private onMouseDown = (e: MouseEvent) => {
		if (!this.canvas) return;
		const rect = this.canvas.getBoundingClientRect();
		const canvasX = e.clientX - rect.left;
		const canvasY = e.clientY - rect.top;

		// Convert canvas coords to world coords.
		const viewportSize = DEFAULT_VIEWPORT_SIZE / this.zoom;
		const scale = DEFAULT_VIEWPORT_SIZE / viewportSize;
		const primaryChunk = this.chunks.get(
			chunkKey(this.chunkX, this.chunkY),
		);
		const meDisplay = primaryChunk?.display[this.playerId];
		const localPlayerX = meDisplay?.x ?? this.chunkSize / 2;
		const localPlayerY = meDisplay?.y ?? this.chunkSize / 2;
		const worldPlayerX = this.chunkX * this.chunkSize + localPlayerX;
		const worldPlayerY = this.chunkY * this.chunkSize + localPlayerY;
		const camX = worldPlayerX - viewportSize / 2;
		const camY = worldPlayerY - viewportSize / 2;

		const worldX = camX + canvasX / scale;
		const worldY = camY + canvasY / scale;

		// Determine which chunk this falls in.
		const targetCx = Math.floor(worldX / this.chunkSize);
		const targetCy = Math.floor(worldY / this.chunkSize);
		const localBlockX = worldX - targetCx * this.chunkSize;
		const localBlockY = worldY - targetCy * this.chunkSize;
		const gridX = Math.floor(localBlockX / BLOCK_SIZE);
		const gridY = Math.floor(localBlockY / BLOCK_SIZE);

		const chunk = this.chunks.get(chunkKey(targetCx, targetCy));
		if (!chunk) return;

		if (e.button === 0) {
			// Left click: place block.
			chunk.conn.placeBlock({ gridX, gridY }).catch(() => {});
		} else if (e.button === 2) {
			// Right click: remove block.
			chunk.conn.removeBlock({ gridX, gridY }).catch(() => {});
		}
	};

	private sendInput() {
		if (this.transferring) return;
		let ix = 0;
		let iy = 0;
		if (this.keys.w || this.keys.ArrowUp) iy -= 1;
		if (this.keys.s || this.keys.ArrowDown) iy += 1;
		if (this.keys.a || this.keys.ArrowLeft) ix -= 1;
		if (this.keys.d || this.keys.ArrowRight) ix += 1;
		const sprint = !!this.keys.Shift;
		if (
			ix !== this.lastIx ||
			iy !== this.lastIy ||
			sprint !== this.lastSprint
		) {
			this.lastIx = ix;
			this.lastIy = iy;
			this.lastSprint = sprint;
			const primary = this.chunks.get(chunkKey(this.chunkX, this.chunkY));
			primary?.conn
				.setInput({ inputX: ix, inputY: iy, sprint })
				.catch(() => {});
		}
	}

	private draw = () => {
		if (this.stopped) return;
		const canvas = this.canvas!;
		const ctx = canvas.getContext("2d")!;
		const viewportSize = DEFAULT_VIEWPORT_SIZE / this.zoom;

		// Get player position from primary chunk.
		const primaryChunk = this.chunks.get(
			chunkKey(this.chunkX, this.chunkY),
		);
		const me = primaryChunk?.players[this.playerId];
		const meDisplay = primaryChunk?.display[this.playerId];
		if (meDisplay && me) {
			meDisplay.x += (me.x - meDisplay.x) * LERP_FACTOR;
			meDisplay.y += (me.y - meDisplay.y) * LERP_FACTOR;
		}

		const localPlayerX = meDisplay?.x ?? this.chunkSize / 2;
		const localPlayerY = meDisplay?.y ?? this.chunkSize / 2;
		const worldPlayerX = this.chunkX * this.chunkSize + localPlayerX;
		const worldPlayerY = this.chunkY * this.chunkSize + localPlayerY;
		const selfColor = me?.color ?? getPlayerColor(this.playerId);

		// Periodically update which chunks we're connected to as the player moves.
		const now = Date.now();
		if (now - this.lastChunkUpdate > 500) {
			this.lastChunkUpdate = now;
			this.updateAdjacentChunks();
		}

		// Camera centered on player.
		const camX = worldPlayerX - viewportSize / 2;
		const camY = worldPlayerY - viewportSize / 2;
		const scale = DEFAULT_VIEWPORT_SIZE / viewportSize;

		ctx.fillStyle = "#000000";
		ctx.fillRect(0, 0, canvas.width, canvas.height);

		ctx.save();
		ctx.scale(scale, scale);

		// Grid lines in world space.
		ctx.strokeStyle = "#1a1a1a";
		ctx.lineWidth = 1 / scale;
		ctx.beginPath();
		const gridStartX = Math.floor(camX / GRID_SPACING) * GRID_SPACING;
		const gridStartY = Math.floor(camY / GRID_SPACING) * GRID_SPACING;
		for (let x = gridStartX; x <= camX + viewportSize; x += GRID_SPACING) {
			ctx.moveTo(x - camX, 0);
			ctx.lineTo(x - camX, viewportSize);
		}
		for (let y = gridStartY; y <= camY + viewportSize; y += GRID_SPACING) {
			ctx.moveTo(0, y - camY);
			ctx.lineTo(viewportSize, y - camY);
		}
		ctx.stroke();

		// Draw blocks from all connected chunks.
		for (const chunk of this.chunks.values()) {
			for (const [blockKey, blockColor] of chunk.blocks) {
				const [gx, gy] = blockKey.split(",").map(Number);
				const wx = chunk.cx * this.chunkSize + gx! * BLOCK_SIZE;
				const wy = chunk.cy * this.chunkSize + gy! * BLOCK_SIZE;
				const sx = wx - camX;
				const sy = wy - camY;
				if (
					sx + BLOCK_SIZE < 0 ||
					sx > viewportSize ||
					sy + BLOCK_SIZE < 0 ||
					sy > viewportSize
				)
					continue;
				ctx.fillStyle = colorWithAlpha(blockColor, 0.3);
				ctx.fillRect(sx, sy, BLOCK_SIZE, BLOCK_SIZE);
				ctx.strokeStyle = colorWithAlpha(blockColor, 0.65);
				ctx.lineWidth = 1 / scale;
				ctx.strokeRect(sx, sy, BLOCK_SIZE, BLOCK_SIZE);
			}
		}

		// Cursor ghost block indicator.
		if (this.mouseCanvasX >= 0 && this.mouseCanvasY >= 0) {
			const mWorldX = camX + this.mouseCanvasX / scale;
			const mWorldY = camY + this.mouseCanvasY / scale;
			const gx = Math.floor(mWorldX / BLOCK_SIZE) * BLOCK_SIZE;
			const gy = Math.floor(mWorldY / BLOCK_SIZE) * BLOCK_SIZE;
			const sx = gx - camX;
			const sy = gy - camY;
			ctx.strokeStyle = colorWithAlpha(selfColor, 0.4);
			ctx.lineWidth = 1 / scale;
			ctx.strokeRect(sx, sy, BLOCK_SIZE, BLOCK_SIZE);
		}

		// Chunk boundaries in world space.
		ctx.strokeStyle = "#2c2c2e";
		ctx.lineWidth = 2 / scale;
		const chunkStartX = Math.floor(camX / this.chunkSize) * this.chunkSize;
		const chunkStartY = Math.floor(camY / this.chunkSize) * this.chunkSize;
		ctx.beginPath();
		for (
			let x = chunkStartX;
			x <= camX + viewportSize + this.chunkSize;
			x += this.chunkSize
		) {
			ctx.moveTo(x - camX, 0);
			ctx.lineTo(x - camX, viewportSize);
		}
		for (
			let y = chunkStartY;
			y <= camY + viewportSize + this.chunkSize;
			y += this.chunkSize
		) {
			ctx.moveTo(0, y - camY);
			ctx.lineTo(viewportSize, y - camY);
		}
		ctx.stroke();

		// Chunk index labels at all four corners of each visible chunk.
		ctx.font = `${11 / scale}px ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace`;
		ctx.fillStyle = "rgba(255, 255, 255, 0.25)";
		const cxStart = Math.floor(camX / this.chunkSize);
		const cyStart = Math.floor(camY / this.chunkSize);
		const cxEnd = Math.floor((camX + viewportSize) / this.chunkSize);
		const cyEnd = Math.floor((camY + viewportSize) / this.chunkSize);
		const pad = 6 / scale;
		const fontSize = 11 / scale;
		for (let cx = cxStart; cx <= cxEnd; cx++) {
			for (let cy = cyStart; cy <= cyEnd; cy++) {
				const label = `${cx},${cy}`;
				const left = cx * this.chunkSize - camX;
				const top = cy * this.chunkSize - camY;
				const right = left + this.chunkSize;
				const bottom = top + this.chunkSize;

				// Top-left.
				ctx.textAlign = "left";
				ctx.fillText(label, left + pad, top + fontSize + pad);
				// Top-right.
				ctx.textAlign = "right";
				ctx.fillText(label, right - pad, top + fontSize + pad);
				// Bottom-left.
				ctx.textAlign = "left";
				ctx.fillText(label, left + pad, bottom - pad);
				// Bottom-right.
				ctx.textAlign = "right";
				ctx.fillText(label, right - pad, bottom - pad);
			}
		}

		// Draw players from all connected chunks.
		for (const chunk of this.chunks.values()) {
			for (const [id, target] of Object.entries(chunk.players)) {
				if (id === this.playerId) continue;
				const d = chunk.display[id];
				if (!d) continue;
				d.x += (target.x - d.x) * LERP_FACTOR;
				d.y += (target.y - d.y) * LERP_FACTOR;

				const wx = chunk.cx * this.chunkSize + d.x;
				const wy = chunk.cy * this.chunkSize + d.y;
				const px = wx - camX;
				const py = wy - camY;

				if (
					px < -50 / scale ||
					px > viewportSize + 50 / scale ||
					py < -50 / scale ||
					py > viewportSize + 50 / scale
				)
					continue;

				ctx.beginPath();
				ctx.arc(px, py, PLAYER_RADIUS / scale, 0, Math.PI * 2);
				ctx.fillStyle = target.color;
				ctx.fill();

				ctx.fillStyle = "#ffffff";
				ctx.font = `${11 / scale}px sans-serif`;
				ctx.textAlign = "center";
				ctx.fillText(
					target.name || id.slice(0, 6),
					px,
					py - PLAYER_RADIUS / scale - 4 / scale,
				);
			}
		}

		// Draw self at center.
		const selfScreenX = viewportSize / 2;
		const selfScreenY = viewportSize / 2;
		ctx.beginPath();
		ctx.arc(
			selfScreenX,
			selfScreenY,
			PLAYER_RADIUS / scale,
			0,
			Math.PI * 2,
		);
		ctx.fillStyle = selfColor;
		ctx.fill();
		ctx.lineWidth = 2 / scale;
		ctx.strokeStyle = "#ffffff";
		ctx.stroke();

		if (me) {
			ctx.fillStyle = "#ffffff";
			ctx.font = `${11 / scale}px sans-serif`;
			ctx.textAlign = "center";
			const fallbackId = this.playerId || "self";
			ctx.fillText(
				me.name || fallbackId.slice(0, 6),
				selfScreenX,
				selfScreenY - PLAYER_RADIUS / scale - 4 / scale,
			);
		}

		ctx.restore();

		// HUD (not affected by zoom).
		ctx.fillStyle = "rgba(0, 0, 0, 0.6)";
		ctx.fillRect(0, 0, canvas.width, 28);
		ctx.fillStyle = "#ffffff";
		ctx.font =
			"12px ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace";
		ctx.textAlign = "left";
		const sprintLabel = this.lastSprint ? "  [SPRINT]" : "";
		ctx.fillText(
			`Chunk: ${this.chunkX},${this.chunkY}  Pos: ${Math.round(localPlayerX)},${Math.round(localPlayerY)}  Zoom: ${this.zoom.toFixed(2)}x${sprintLabel}`,
			8,
			18,
		);

		// Debug: connected chunks list on the right side.
		const chunkEntries = Array.from(this.chunks.entries()).sort(
			([a], [b]) => a.localeCompare(b),
		);
		const totalPlayers = chunkEntries.reduce(
			(sum, [, c]) => sum + Object.keys(c.players).length,
			0,
		);
		ctx.fillStyle = "rgba(0, 0, 0, 0.6)";
		const debugX = canvas.width - 130;
		const lineH = 14;
		const debugH = (chunkEntries.length + 2) * lineH + 8;
		ctx.fillRect(debugX - 6, 0, 136, debugH);

		ctx.fillStyle = "#ffffff";
		ctx.font =
			"11px ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace";
		ctx.textAlign = "left";
		let dy = 14;
		ctx.fillText(`Chunks (${totalPlayers} players):`, debugX, dy);
		dy += lineH;
		for (const [key, chunk] of chunkEntries) {
			const isPrimary =
				chunk.cx === this.chunkX && chunk.cy === this.chunkY;
			const pCount = Object.keys(chunk.players).length;
			ctx.fillStyle = isPrimary ? "#ff4f00" : "#8e8e93";
			ctx.fillText(
				`${key}${pCount > 0 ? ` (${pCount})` : ""}${isPrimary ? " *" : ""}`,
				debugX,
				dy,
			);
			dy += lineH;
		}
		this.rafId = requestAnimationFrame(this.draw);
	};
}

function chunkKey(cx: number, cy: number): string {
	return `${cx},${cy}`;
}

function parseChunkKey(key: string): [number, number] {
	const [cx, cy] = key.split(",").map(Number);
	return [cx!, cy!];
}

function resolveChunkForPosition(
	x: number,
	y: number,
): { chunkKey: [string, number, number]; spawnX: number; spawnY: number } {
	const chunkX = Math.floor(x / CHUNK_SIZE);
	const chunkY = Math.floor(y / CHUNK_SIZE);
	return {
		chunkKey: [WORLD_ID, chunkX, chunkY],
		spawnX: ((x % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE,
		spawnY: ((y % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE,
	};
}

function createPlayerId(): string {
	if (typeof globalThis.crypto?.randomUUID === "function") {
		return globalThis.crypto.randomUUID();
	}
	const random = Math.floor(Math.random() * 1_000_000_000).toString(36);
	return `player-${Date.now().toString(36)}-${random}`;
}

function sleep(ms: number): Promise<void> {
	return new Promise((resolve) => {
		window.setTimeout(resolve, ms);
	});
}

function colorWithAlpha(color: string, alpha: number): string {
	const normalized = color.trim();
	if (/^#[0-9a-fA-F]{6}$/.test(normalized)) {
		const r = Number.parseInt(normalized.slice(1, 3), 16);
		const g = Number.parseInt(normalized.slice(3, 5), 16);
		const b = Number.parseInt(normalized.slice(5, 7), 16);
		return `rgba(${r}, ${g}, ${b}, ${alpha})`;
	}
	return color;
}
```

- `connectToChunk` calls `getOrCreate([WORLD_ID, String(cx), String(cy)])`, which creates the chunk on first use. The shared `playerId` lets each chunk match the connection to its player.
- `updateAdjacentChunks` keeps a connection open to every chunk the camera can see and disposes the rest. Zooming out opens more. This is the interest management: a client only gets snapshots from chunks it is connected to.
- Snapshots only set target positions. The `requestAnimationFrame` draw loop moves each player 20% (`LERP_FACTOR`) toward its target every frame, outside React state.

When the player walks into an edge, the client moves them to the next chunk:

open-world-game.ts:

```ts
import { CHUNK_SIZE, WORLD_ID } from "../../../src/actors/open-world/config.ts";
import { getPlayerColor } from "../../../src/actors/player-color.ts";
import type { OpenWorldChunkConn } from "../../actor-types.ts";
import type { GameClient } from "../../client.ts";
import type { OpenWorldMatchInfo } from "./menu.tsx";

const PLAYER_RADIUS = 12;
const LERP_FACTOR = 0.2;
const GRID_SPACING = 50;
const BLOCK_SIZE = 50;
const DEFAULT_VIEWPORT_SIZE = 600;
const MIN_ZOOM = 0.25;
const MAX_ZOOM = 2;
const ZOOM_STEP = 0.15;

interface ChunkConnection {
	cx: number;
	cy: number;
	conn: OpenWorldChunkConn;
	players: Record<
		string,
		{ x: number; y: number; name: string; color: string }
	>;
	display: Record<string, { x: number; y: number }>;
	blocks: Map<string, string>;
	selfPresent: boolean;
}

interface ChunkSnapshot {
	chunkSize: number;
	chunkX: number;
	chunkY: number;
	selfPlayerId: string | null;
	players: Record<
		string,
		{ x: number; y: number; name: string; color: string }
	>;
	blocks?: Record<string, string> | string[];
}

export class OpenWorldGame {
	private stopped = false;
	private rafId = 0;
	private chunkSize = CHUNK_SIZE;
	private chunkX: number;
	private chunkY: number;
	private readonly playerId = createPlayerId();
	private playerName: string;
	private keys: Record<string, boolean> = {};
	private lastIx = 0;
	private lastIy = 0;
	private lastSprint = false;
	private transferring = false;
	private mouseCanvasX = -1;
	private mouseCanvasY = -1;
	private lastChunkUpdate = 0;
	private botInterval = 0;
	private zoom = 1;
	private chunks = new Map<string, ChunkConnection>();

	constructor(
		private canvas: HTMLCanvasElement | null,
		private client: GameClient,
		matchInfo: OpenWorldMatchInfo,
		private options: { bot?: boolean } = {},
	) {
		this.chunkX = matchInfo.chunkKey[1];
		this.chunkY = matchInfo.chunkKey[2];
		this.playerName = matchInfo.playerName;

		this.connectToChunk(matchInfo.chunkKey[1], matchInfo.chunkKey[2]);
		void this.setPrimaryChunk(
			matchInfo.chunkKey[1],
			matchInfo.chunkKey[2],
			matchInfo.spawnX,
			matchInfo.spawnY,
		);

		if (options.bot) {
			this.botInterval = window.setInterval(() => {
				const ix = Math.floor(Math.random() * 3) - 1;
				const iy = Math.floor(Math.random() * 3) - 1;
				this.lastIx = ix;
				this.lastIy = iy;
				const primary = this.chunks.get(
					chunkKey(this.chunkX, this.chunkY),
				);
				primary?.conn
					.setInput({ inputX: ix, inputY: iy })
					.catch(() => {});
			}, 500);
		} else if (canvas) {
			window.addEventListener("keydown", this.onKeyDown);
			window.addEventListener("keyup", this.onKeyUp);
			canvas.addEventListener("mousedown", this.onMouseDown);
			canvas.addEventListener("mousemove", this.onMouseMove);
			canvas.addEventListener("mouseleave", this.onMouseLeave);
			canvas.addEventListener("contextmenu", this.onContextMenu);
			this.rafId = requestAnimationFrame(this.draw);
		}
	}

	private connectToChunk(cx: number, cy: number): ChunkConnection {
		const key = chunkKey(cx, cy);
		const existing = this.chunks.get(key);
		if (existing) return existing;

		const conn = this.client.openWorldChunk
			.getOrCreate([WORLD_ID, String(cx), String(cy)], {
				params: { playerId: this.playerId },
			})
			.connect();

		const chunk: ChunkConnection = {
			cx,
			cy,
			conn,
			players: {},
			display: {},
			blocks: new Map(),
			selfPresent: false,
		};

		conn.on("snapshot", (snap) => {
			this.chunkSize = snap.chunkSize;
			const isPrimaryChunk =
				chunk.cx === this.chunkX && chunk.cy === this.chunkY;
			chunk.selfPresent = snap.selfPlayerId === this.playerId;
			for (const [id, pos] of Object.entries(snap.players)) {
				chunk.players[id] = pos;
				if (!chunk.display[id])
					chunk.display[id] = { x: pos.x, y: pos.y };
			}
			for (const id of Object.keys(chunk.players)) {
				if (!snap.players[id]) {
					delete chunk.players[id];
					delete chunk.display[id];
				}
			}
			if (snap.blocks) {
				if (Array.isArray(snap.blocks)) {
					chunk.blocks = new Map(
						snap.blocks.map((blockKey) => [blockKey, "#ff4f00"]),
					);
				} else {
					chunk.blocks = new Map(Object.entries(snap.blocks));
				}
			}

			// Client-driven chunk transfer detection (only for primary chunk).
			if (isPrimaryChunk) {
				const me = snap.players[this.playerId];
				if (me && !this.transferring) {
					const atLeft = me.x <= 0 && this.lastIx < 0;
					const atRight =
						me.x >= this.chunkSize - 1 && this.lastIx > 0;
					const atTop = me.y <= 0 && this.lastIy < 0;
					const atBottom =
						me.y >= this.chunkSize - 1 && this.lastIy > 0;
					if (atLeft || atRight || atTop || atBottom) {
						this.initiateTransfer(me.x, me.y);
					}
				}
			}
		});

		this.chunks.set(key, chunk);
		return chunk;
	}

	private async setPrimaryChunk(
		cx: number,
		cy: number,
		spawnX: number,
		spawnY: number,
	) {
		const previousChunkX = this.chunkX;
		const previousChunkY = this.chunkY;
		const oldPrimaryKey = chunkKey(previousChunkX, previousChunkY);
		const newPrimaryKey = chunkKey(cx, cy);
		const oldPrimary = this.chunks.get(oldPrimaryKey);

		const newPrimary = this.connectToChunk(cx, cy);

		await newPrimary.conn
			.enterChunk({
				name: this.playerName,
				spawnX,
				spawnY,
			})
			.catch(() => {});

		// Keep the old primary active until the target chunk confirms this connection's presence.
		const ready = await this.waitForSelfInChunk(cx, cy);
		if (!ready || this.stopped) {
			if (oldPrimaryKey !== newPrimaryKey) {
				newPrimary.conn.leaveChunk().catch(() => {});
			}
			return;
		}

		this.chunkX = cx;
		this.chunkY = cy;
		this.updateAdjacentChunks();

		if (oldPrimary && oldPrimaryKey !== newPrimaryKey) {
			oldPrimary.conn.leaveChunk().catch(() => {});
		}

		newPrimary.conn
			.setInput({
				inputX: this.lastIx,
				inputY: this.lastIy,
				sprint: this.lastSprint,
			})
			.catch(() => {});
	}

	private async waitForSelfInChunk(cx: number, cy: number): Promise<boolean> {
		const key = chunkKey(cx, cy);
		const deadline = Date.now() + 1500;
		while (!this.stopped && Date.now() < deadline) {
			const chunk = this.chunks.get(key);
			if (chunk?.selfPresent) return true;
			await sleep(16);
		}
		return false;
	}

	/** Compute which chunk indices are visible based on player position and zoom. */
	private getVisibleChunkRange(): {
		minCx: number;
		maxCx: number;
		minCy: number;
		maxCy: number;
	} {
		const primaryChunk = this.chunks.get(
			chunkKey(this.chunkX, this.chunkY),
		);
		const me = primaryChunk?.display[this.playerId];
		const localX = me?.x ?? this.chunkSize / 2;
		const localY = me?.y ?? this.chunkSize / 2;
		const worldX = this.chunkX * this.chunkSize + localX;
		const worldY = this.chunkY * this.chunkSize + localY;

		const viewportSize = DEFAULT_VIEWPORT_SIZE / this.zoom;
		const halfView = viewportSize / 2;

		return {
			minCx: Math.floor((worldX - halfView) / this.chunkSize),
			maxCx: Math.floor((worldX + halfView) / this.chunkSize),
			minCy: Math.floor((worldY - halfView) / this.chunkSize),
			maxCy: Math.floor((worldY + halfView) / this.chunkSize),
		};
	}

	private updateAdjacentChunks() {
		const { minCx, maxCx, minCy, maxCy } = this.getVisibleChunkRange();

		const needed = new Set<string>();
		for (let cx = minCx; cx <= maxCx; cx++) {
			for (let cy = minCy; cy <= maxCy; cy++) {
				needed.add(chunkKey(cx, cy));
			}
		}

		// Disconnect chunks that are no longer needed.
		for (const [key, chunk] of this.chunks) {
			if (
				!needed.has(key) &&
				!(chunk.cx === this.chunkX && chunk.cy === this.chunkY)
			) {
				chunk.conn.dispose().catch(() => {});
				this.chunks.delete(key);
			}
		}

		// Connect to new chunks as observer (read-only).
		for (const key of needed) {
			if (!this.chunks.has(key)) {
				const [cx, cy] = parseChunkKey(key);
				this.connectToChunk(cx, cy);
			}
		}
	}

	private async initiateTransfer(localX: number, localY: number) {
		this.transferring = true;
		try {
			let absX = this.chunkX * this.chunkSize + localX;
			let absY = this.chunkY * this.chunkSize + localY;
			if (this.lastIx < 0) absX -= 1;
			else if (this.lastIx > 0) absX += 1;
			if (this.lastIy < 0) absY -= 1;
			else if (this.lastIy > 0) absY += 1;
			const response = resolveChunkForPosition(absX, absY);
			if (this.stopped) return;
			await this.setPrimaryChunk(
				response.chunkKey[1],
				response.chunkKey[2],
				response.spawnX,
				response.spawnY,
			);
		} catch {
			// Transfer failed, stay in current chunk.
		} finally {
			this.transferring = false;
			// Flush the latest key state after transfer so key-up events that happened
			// during transfer do not leave stale movement latched on the new chunk.
			this.sendInput();
		}
	}

	destroy() {
		this.stopped = true;
		cancelAnimationFrame(this.rafId);
		clearInterval(this.botInterval);
		if (!this.options.bot && this.canvas) {
			window.removeEventListener("keydown", this.onKeyDown);
			window.removeEventListener("keyup", this.onKeyUp);
			this.canvas.removeEventListener("mousedown", this.onMouseDown);
			this.canvas.removeEventListener("mousemove", this.onMouseMove);
			this.canvas.removeEventListener("mouseleave", this.onMouseLeave);
			this.canvas.removeEventListener("contextmenu", this.onContextMenu);
		}
		for (const chunk of this.chunks.values()) {
			chunk.conn.dispose().catch(() => {});
		}
		this.chunks.clear();
	}

	private setKey(key: string, value: boolean) {
		this.keys[key] = value;
		// Normalize single characters to lowercase so Shift doesn't leave stuck keys.
		if (key.length === 1) this.keys[key.toLowerCase()] = value;
	}

	private onKeyDown = (e: KeyboardEvent) => {
		// Zoom controls.
		if (e.key === "=" || e.key === "+") {
			this.zoom = Math.min(MAX_ZOOM, this.zoom + ZOOM_STEP);
			this.updateAdjacentChunks();
			return;
		}
		if (e.key === "-" || e.key === "_") {
			this.zoom = Math.max(MIN_ZOOM, this.zoom - ZOOM_STEP);
			this.updateAdjacentChunks();
			return;
		}
		this.setKey(e.key, true);
		this.sendInput();
	};

	private onKeyUp = (e: KeyboardEvent) => {
		this.setKey(e.key, false);
		this.sendInput();
	};

	private onContextMenu = (e: MouseEvent) => {
		e.preventDefault();
	};

	private onMouseMove = (e: MouseEvent) => {
		if (!this.canvas) return;
		const rect = this.canvas.getBoundingClientRect();
		this.mouseCanvasX = e.clientX - rect.left;
		this.mouseCanvasY = e.clientY - rect.top;
	};

	private onMouseLeave = () => {
		this.mouseCanvasX = -1;
		this.mouseCanvasY = -1;
	};

	private onMouseDown = (e: MouseEvent) => {
		if (!this.canvas) return;
		const rect = this.canvas.getBoundingClientRect();
		const canvasX = e.clientX - rect.left;
		const canvasY = e.clientY - rect.top;

		// Convert canvas coords to world coords.
		const viewportSize = DEFAULT_VIEWPORT_SIZE / this.zoom;
		const scale = DEFAULT_VIEWPORT_SIZE / viewportSize;
		const primaryChunk = this.chunks.get(
			chunkKey(this.chunkX, this.chunkY),
		);
		const meDisplay = primaryChunk?.display[this.playerId];
		const localPlayerX = meDisplay?.x ?? this.chunkSize / 2;
		const localPlayerY = meDisplay?.y ?? this.chunkSize / 2;
		const worldPlayerX = this.chunkX * this.chunkSize + localPlayerX;
		const worldPlayerY = this.chunkY * this.chunkSize + localPlayerY;
		const camX = worldPlayerX - viewportSize / 2;
		const camY = worldPlayerY - viewportSize / 2;

		const worldX = camX + canvasX / scale;
		const worldY = camY + canvasY / scale;

		// Determine which chunk this falls in.
		const targetCx = Math.floor(worldX / this.chunkSize);
		const targetCy = Math.floor(worldY / this.chunkSize);
		const localBlockX = worldX - targetCx * this.chunkSize;
		const localBlockY = worldY - targetCy * this.chunkSize;
		const gridX = Math.floor(localBlockX / BLOCK_SIZE);
		const gridY = Math.floor(localBlockY / BLOCK_SIZE);

		const chunk = this.chunks.get(chunkKey(targetCx, targetCy));
		if (!chunk) return;

		if (e.button === 0) {
			// Left click: place block.
			chunk.conn.placeBlock({ gridX, gridY }).catch(() => {});
		} else if (e.button === 2) {
			// Right click: remove block.
			chunk.conn.removeBlock({ gridX, gridY }).catch(() => {});
		}
	};

	private sendInput() {
		if (this.transferring) return;
		let ix = 0;
		let iy = 0;
		if (this.keys.w || this.keys.ArrowUp) iy -= 1;
		if (this.keys.s || this.keys.ArrowDown) iy += 1;
		if (this.keys.a || this.keys.ArrowLeft) ix -= 1;
		if (this.keys.d || this.keys.ArrowRight) ix += 1;
		const sprint = !!this.keys.Shift;
		if (
			ix !== this.lastIx ||
			iy !== this.lastIy ||
			sprint !== this.lastSprint
		) {
			this.lastIx = ix;
			this.lastIy = iy;
			this.lastSprint = sprint;
			const primary = this.chunks.get(chunkKey(this.chunkX, this.chunkY));
			primary?.conn
				.setInput({ inputX: ix, inputY: iy, sprint })
				.catch(() => {});
		}
	}

	private draw = () => {
		if (this.stopped) return;
		const canvas = this.canvas!;
		const ctx = canvas.getContext("2d")!;
		const viewportSize = DEFAULT_VIEWPORT_SIZE / this.zoom;

		// Get player position from primary chunk.
		const primaryChunk = this.chunks.get(
			chunkKey(this.chunkX, this.chunkY),
		);
		const me = primaryChunk?.players[this.playerId];
		const meDisplay = primaryChunk?.display[this.playerId];
		if (meDisplay && me) {
			meDisplay.x += (me.x - meDisplay.x) * LERP_FACTOR;
			meDisplay.y += (me.y - meDisplay.y) * LERP_FACTOR;
		}

		const localPlayerX = meDisplay?.x ?? this.chunkSize / 2;
		const localPlayerY = meDisplay?.y ?? this.chunkSize / 2;
		const worldPlayerX = this.chunkX * this.chunkSize + localPlayerX;
		const worldPlayerY = this.chunkY * this.chunkSize + localPlayerY;
		const selfColor = me?.color ?? getPlayerColor(this.playerId);

		// Periodically update which chunks we're connected to as the player moves.
		const now = Date.now();
		if (now - this.lastChunkUpdate > 500) {
			this.lastChunkUpdate = now;
			this.updateAdjacentChunks();
		}

		// Camera centered on player.
		const camX = worldPlayerX - viewportSize / 2;
		const camY = worldPlayerY - viewportSize / 2;
		const scale = DEFAULT_VIEWPORT_SIZE / viewportSize;

		ctx.fillStyle = "#000000";
		ctx.fillRect(0, 0, canvas.width, canvas.height);

		ctx.save();
		ctx.scale(scale, scale);

		// Grid lines in world space.
		ctx.strokeStyle = "#1a1a1a";
		ctx.lineWidth = 1 / scale;
		ctx.beginPath();
		const gridStartX = Math.floor(camX / GRID_SPACING) * GRID_SPACING;
		const gridStartY = Math.floor(camY / GRID_SPACING) * GRID_SPACING;
		for (let x = gridStartX; x <= camX + viewportSize; x += GRID_SPACING) {
			ctx.moveTo(x - camX, 0);
			ctx.lineTo(x - camX, viewportSize);
		}
		for (let y = gridStartY; y <= camY + viewportSize; y += GRID_SPACING) {
			ctx.moveTo(0, y - camY);
			ctx.lineTo(viewportSize, y - camY);
		}
		ctx.stroke();

		// Draw blocks from all connected chunks.
		for (const chunk of this.chunks.values()) {
			for (const [blockKey, blockColor] of chunk.blocks) {
				const [gx, gy] = blockKey.split(",").map(Number);
				const wx = chunk.cx * this.chunkSize + gx! * BLOCK_SIZE;
				const wy = chunk.cy * this.chunkSize + gy! * BLOCK_SIZE;
				const sx = wx - camX;
				const sy = wy - camY;
				if (
					sx + BLOCK_SIZE < 0 ||
					sx > viewportSize ||
					sy + BLOCK_SIZE < 0 ||
					sy > viewportSize
				)
					continue;
				ctx.fillStyle = colorWithAlpha(blockColor, 0.3);
				ctx.fillRect(sx, sy, BLOCK_SIZE, BLOCK_SIZE);
				ctx.strokeStyle = colorWithAlpha(blockColor, 0.65);
				ctx.lineWidth = 1 / scale;
				ctx.strokeRect(sx, sy, BLOCK_SIZE, BLOCK_SIZE);
			}
		}

		// Cursor ghost block indicator.
		if (this.mouseCanvasX >= 0 && this.mouseCanvasY >= 0) {
			const mWorldX = camX + this.mouseCanvasX / scale;
			const mWorldY = camY + this.mouseCanvasY / scale;
			const gx = Math.floor(mWorldX / BLOCK_SIZE) * BLOCK_SIZE;
			const gy = Math.floor(mWorldY / BLOCK_SIZE) * BLOCK_SIZE;
			const sx = gx - camX;
			const sy = gy - camY;
			ctx.strokeStyle = colorWithAlpha(selfColor, 0.4);
			ctx.lineWidth = 1 / scale;
			ctx.strokeRect(sx, sy, BLOCK_SIZE, BLOCK_SIZE);
		}

		// Chunk boundaries in world space.
		ctx.strokeStyle = "#2c2c2e";
		ctx.lineWidth = 2 / scale;
		const chunkStartX = Math.floor(camX / this.chunkSize) * this.chunkSize;
		const chunkStartY = Math.floor(camY / this.chunkSize) * this.chunkSize;
		ctx.beginPath();
		for (
			let x = chunkStartX;
			x <= camX + viewportSize + this.chunkSize;
			x += this.chunkSize
		) {
			ctx.moveTo(x - camX, 0);
			ctx.lineTo(x - camX, viewportSize);
		}
		for (
			let y = chunkStartY;
			y <= camY + viewportSize + this.chunkSize;
			y += this.chunkSize
		) {
			ctx.moveTo(0, y - camY);
			ctx.lineTo(viewportSize, y - camY);
		}
		ctx.stroke();

		// Chunk index labels at all four corners of each visible chunk.
		ctx.font = `${11 / scale}px ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace`;
		ctx.fillStyle = "rgba(255, 255, 255, 0.25)";
		const cxStart = Math.floor(camX / this.chunkSize);
		const cyStart = Math.floor(camY / this.chunkSize);
		const cxEnd = Math.floor((camX + viewportSize) / this.chunkSize);
		const cyEnd = Math.floor((camY + viewportSize) / this.chunkSize);
		const pad = 6 / scale;
		const fontSize = 11 / scale;
		for (let cx = cxStart; cx <= cxEnd; cx++) {
			for (let cy = cyStart; cy <= cyEnd; cy++) {
				const label = `${cx},${cy}`;
				const left = cx * this.chunkSize - camX;
				const top = cy * this.chunkSize - camY;
				const right = left + this.chunkSize;
				const bottom = top + this.chunkSize;

				// Top-left.
				ctx.textAlign = "left";
				ctx.fillText(label, left + pad, top + fontSize + pad);
				// Top-right.
				ctx.textAlign = "right";
				ctx.fillText(label, right - pad, top + fontSize + pad);
				// Bottom-left.
				ctx.textAlign = "left";
				ctx.fillText(label, left + pad, bottom - pad);
				// Bottom-right.
				ctx.textAlign = "right";
				ctx.fillText(label, right - pad, bottom - pad);
			}
		}

		// Draw players from all connected chunks.
		for (const chunk of this.chunks.values()) {
			for (const [id, target] of Object.entries(chunk.players)) {
				if (id === this.playerId) continue;
				const d = chunk.display[id];
				if (!d) continue;
				d.x += (target.x - d.x) * LERP_FACTOR;
				d.y += (target.y - d.y) * LERP_FACTOR;

				const wx = chunk.cx * this.chunkSize + d.x;
				const wy = chunk.cy * this.chunkSize + d.y;
				const px = wx - camX;
				const py = wy - camY;

				if (
					px < -50 / scale ||
					px > viewportSize + 50 / scale ||
					py < -50 / scale ||
					py > viewportSize + 50 / scale
				)
					continue;

				ctx.beginPath();
				ctx.arc(px, py, PLAYER_RADIUS / scale, 0, Math.PI * 2);
				ctx.fillStyle = target.color;
				ctx.fill();

				ctx.fillStyle = "#ffffff";
				ctx.font = `${11 / scale}px sans-serif`;
				ctx.textAlign = "center";
				ctx.fillText(
					target.name || id.slice(0, 6),
					px,
					py - PLAYER_RADIUS / scale - 4 / scale,
				);
			}
		}

		// Draw self at center.
		const selfScreenX = viewportSize / 2;
		const selfScreenY = viewportSize / 2;
		ctx.beginPath();
		ctx.arc(
			selfScreenX,
			selfScreenY,
			PLAYER_RADIUS / scale,
			0,
			Math.PI * 2,
		);
		ctx.fillStyle = selfColor;
		ctx.fill();
		ctx.lineWidth = 2 / scale;
		ctx.strokeStyle = "#ffffff";
		ctx.stroke();

		if (me) {
			ctx.fillStyle = "#ffffff";
			ctx.font = `${11 / scale}px sans-serif`;
			ctx.textAlign = "center";
			const fallbackId = this.playerId || "self";
			ctx.fillText(
				me.name || fallbackId.slice(0, 6),
				selfScreenX,
				selfScreenY - PLAYER_RADIUS / scale - 4 / scale,
			);
		}

		ctx.restore();

		// HUD (not affected by zoom).
		ctx.fillStyle = "rgba(0, 0, 0, 0.6)";
		ctx.fillRect(0, 0, canvas.width, 28);
		ctx.fillStyle = "#ffffff";
		ctx.font =
			"12px ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace";
		ctx.textAlign = "left";
		const sprintLabel = this.lastSprint ? "  [SPRINT]" : "";
		ctx.fillText(
			`Chunk: ${this.chunkX},${this.chunkY}  Pos: ${Math.round(localPlayerX)},${Math.round(localPlayerY)}  Zoom: ${this.zoom.toFixed(2)}x${sprintLabel}`,
			8,
			18,
		);

		// Debug: connected chunks list on the right side.
		const chunkEntries = Array.from(this.chunks.entries()).sort(
			([a], [b]) => a.localeCompare(b),
		);
		const totalPlayers = chunkEntries.reduce(
			(sum, [, c]) => sum + Object.keys(c.players).length,
			0,
		);
		ctx.fillStyle = "rgba(0, 0, 0, 0.6)";
		const debugX = canvas.width - 130;
		const lineH = 14;
		const debugH = (chunkEntries.length + 2) * lineH + 8;
		ctx.fillRect(debugX - 6, 0, 136, debugH);

		ctx.fillStyle = "#ffffff";
		ctx.font =
			"11px ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace";
		ctx.textAlign = "left";
		let dy = 14;
		ctx.fillText(`Chunks (${totalPlayers} players):`, debugX, dy);
		dy += lineH;
		for (const [key, chunk] of chunkEntries) {
			const isPrimary =
				chunk.cx === this.chunkX && chunk.cy === this.chunkY;
			const pCount = Object.keys(chunk.players).length;
			ctx.fillStyle = isPrimary ? "#ff4f00" : "#8e8e93";
			ctx.fillText(
				`${key}${pCount > 0 ? ` (${pCount})` : ""}${isPrimary ? " *" : ""}`,
				debugX,
				dy,
			);
			dy += lineH;
		}
		this.rafId = requestAnimationFrame(this.draw);
	};
}

function chunkKey(cx: number, cy: number): string {
	return `${cx},${cy}`;
}

function parseChunkKey(key: string): [number, number] {
	const [cx, cy] = key.split(",").map(Number);
	return [cx!, cy!];
}

function resolveChunkForPosition(
	x: number,
	y: number,
): { chunkKey: [string, number, number]; spawnX: number; spawnY: number } {
	const chunkX = Math.floor(x / CHUNK_SIZE);
	const chunkY = Math.floor(y / CHUNK_SIZE);
	return {
		chunkKey: [WORLD_ID, chunkX, chunkY],
		spawnX: ((x % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE,
		spawnY: ((y % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE,
	};
}

function createPlayerId(): string {
	if (typeof globalThis.crypto?.randomUUID === "function") {
		return globalThis.crypto.randomUUID();
	}
	const random = Math.floor(Math.random() * 1_000_000_000).toString(36);
	return `player-${Date.now().toString(36)}-${random}`;
}

function sleep(ms: number): Promise<void> {
	return new Promise((resolve) => {
		window.setTimeout(resolve, ms);
	});
}

function colorWithAlpha(color: string, alpha: number): string {
	const normalized = color.trim();
	if (/^#[0-9a-fA-F]{6}$/.test(normalized)) {
		const r = Number.parseInt(normalized.slice(1, 3), 16);
		const g = Number.parseInt(normalized.slice(3, 5), 16);
		const b = Number.parseInt(normalized.slice(5, 7), 16);
		return `rgba(${r}, ${g}, ${b}, ${alpha})`;
	}
	return color;
}
```

- The snapshot handler calls `initiateTransfer` when the player is at an edge and still pushing toward it. It converts to world coordinates, steps one unit past the edge, and resolves the new chunk key.
- `setPrimaryChunk` calls `enterChunk` on the new chunk and waits up to 1.5 seconds for a snapshot whose `selfPlayerId` is this player. Only then does it call `leaveChunk` on the old chunk, so the player is never missing from both. If the wait times out, it leaves the new chunk and stays where it was.
- Input is stored per chunk, so it sends the current direction to the new chunk right away.

### Try it

Run the example:

```sh
git clone https://github.com/rivet-dev/rivet.git
cd rivet/examples/multiplayer-game-patterns
pnpm install
pnpm dev
```

Open `http://localhost:5173`, choose **Open World**, and click **Enter World**. Move with WASD, sprint with Shift, and zoom with + and -. Left click places a block and right click removes it. Walk past a chunk edge and the HUD at the top switches to the new chunk, while the list on the right shows every chunk you are connected to. Click **Add Bot**, or open a second tab, to see other players in nearby chunks.

## Next steps

- [2D Physics Game](/guides/physics-2d-game): run a physics engine in a world Actor's tick loop.
- [Lifecycle](/actors/docs/lifecycle): how `run` loops start and stop.
- [SQLite](/actors/docs/sqlite): store large chunk data with queries and indexes.
