Skip to main content
Games

Open World Game

Build a Minecraft-style open world that is split into chunks, with one Rivet Actor per chunk and players walking between them.

See the full example on GitHub. View on GitHub
Playerstanding in chunk 0, 0Other playerstanding in chunk 1, 0openWorldChunkkey default, 0, 0openWorldChunkkey default, 1, 0enterChunk, setInput1enterChunk, setInput1snapshot every tick2snapshot to watchers2enterChunk at the edge3leaveChunk4One per chunkRivet ActorCallReply

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 SQLite database.
  • A browser client that watches every chunk in view and moves the player to the next chunk at an edge.

Install

npm add rivetkit

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:

  • The key is [worldId, chunkX, chunkY]. createVars reads the coordinates from c.key, so a chunk knows where it is without any input. It also loads the chunk’s saved blocks, players, and tick from SQLite into c.vars. Connections only live in memory, so restored players start without one. 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.
  • After a restart, the first connection whose playerId matches a restored player takes that player back, so a reconnecting client keeps control where it was.
  • 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.

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, 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 background task that onWake launches on every start:

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

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

interface PlayerEntry {
	connId: string | null;
	name: string;
	color: string;
	x: number;
	y: number;
	inputX: number;
	inputY: number;
	sprint: boolean;
	// Set when a restart drops the player's connection, and cleared when the
	// same player ID connects again.
	disconnectedAt: number | null;
}

type PlayerRow = {
	player_id: string;
	name: string;
	color: string;
	x: number;
	y: number;
};

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

interface ChunkVars {
	worldId: string;
	chunkX: number;
	chunkY: number;
	tick: number;
	connections: Record<string, ConnectionMeta>;
	players: Record<string, PlayerEntry>;
	blocks: Record<string, string>;
	// `onWake` stores its tick loop here so `onSleep` and `onDestroy` can wait
	// for it.
	loop: Promise<void> | null;
}

// 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 chunks.
// 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>(),
	},
	db: db({
		onMigrate: async (db) => {
			await db.execute(`
				CREATE TABLE IF NOT EXISTS blocks (
					grid_x INTEGER NOT NULL,
					grid_y INTEGER NOT NULL,
					color TEXT NOT NULL,
					PRIMARY KEY (grid_x, grid_y)
				)
			`);
			await db.execute(`
				CREATE TABLE IF NOT EXISTS chunk (
					id INTEGER PRIMARY KEY CHECK (id = 1),
					tick INTEGER NOT NULL
				)
			`);
			await db.execute(`
				CREATE TABLE IF NOT EXISTS players (
					player_id TEXT PRIMARY KEY,
					name TEXT NOT NULL,
					color TEXT NOT NULL,
					x REAL NOT NULL,
					y REAL NOT NULL
				)
			`);
		},
	}),
	createVars: async (c): Promise<ChunkVars> => {
		const chunkX = Number(c.key[1] ?? "0");
		const chunkY = Number(c.key[2] ?? "0");

		// Blocks are saved in SQLite. Load them into memory on every start.
		const rows = await c.db.execute<{
			grid_x: number;
			grid_y: number;
			color: string;
		}>("SELECT grid_x, grid_y, color FROM blocks");
		const blocks: Record<string, string> = {};
		for (const row of rows) {
			blocks[`${row.grid_x},${row.grid_y}`] = row.color;
		}

		// The tick loop saves the tick and players periodically. Restore them
		// without their old connections, which did not survive the restart.
		const [savedChunk] = await c.db.execute<{ tick: number }>(
			"SELECT tick FROM chunk WHERE id = 1",
		);
		const savedPlayers = await c.db.execute<PlayerRow>(
			"SELECT player_id, name, color, x, y FROM players",
		);
		const players: Record<string, PlayerEntry> = {};
		for (const row of savedPlayers) {
			players[row.player_id] = {
				connId: null,
				name: row.name,
				color: row.color,
				x: row.x,
				y: row.y,
				inputX: 0,
				inputY: 0,
				sprint: false,
				disconnectedAt: Date.now(),
			};
		}

		return {
			worldId: c.key[0] ?? "",
			chunkX: Number.isFinite(chunkX) ? chunkX : 0,
			chunkY: Number.isFinite(chunkY) ? chunkY : 0,
			tick: savedChunk?.tick ?? 0,
			connections: {},
			players,
			blocks,
			loop: null,
		};
	},
	onConnect: (c, conn) => {
		const playerId = parsePlayerId(conn.params) ?? conn.id;
		const meta: ConnectionMeta = { playerId, inChunk: false };
		c.vars.connections[conn.id] = meta;

		// A player restored after a restart is waiting for its client to
		// reconnect. Hand it back to the first connection with its ID.
		const restored = c.vars.players[playerId];
		if (restored && restored.connId === null) {
			restored.connId = conn.id;
			restored.disconnectedAt = null;
			meta.inChunk = true;
		}
		broadcastSnapshot(c);
	},
	onDisconnect: (c, conn) => {
		const meta = c.vars.connections[conn.id];
		if (!meta) return;
		delete c.vars.connections[conn.id];

		const player = c.vars.players[meta.playerId];
		if (player?.connId === conn.id) {
			delete c.vars.players[meta.playerId];
		}
		broadcastSnapshot(c);
	},
	// `onWake` runs on every start, so the loop restarts after a sleep or crash.
	onWake: (c) => {
		c.vars.loop = runChunk(c).catch((error) => {
			c.log.error({ msg: "chunk loop failed", error });
		});
	},
	// `c.abortSignal` fires on shutdown, so the loop saves and exits on its own.
	onSleep: async (c) => {
		await c.vars.loop;
	},
	// The database closes right after `onDestroy`, so wait for the final save.
	onDestroy: async (c) => {
		await c.vars.loop;
	},
	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.vars.players[meta.playerId];
			c.vars.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,
				disconnectedAt: null,
			};
			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.vars.players[meta.playerId];
			c.vars.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,
				disconnectedAt: null,
			};
			meta.inChunk = true;
			broadcastSnapshot(c);
		},
		leaveChunk: (c) => {
			const meta = getConnectionMeta(c, c.conn.id);
			if (!meta) return;

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

			meta.inChunk = false;
			const player = c.vars.players[meta.playerId];
			if (player?.connId === c.conn.id) {
				delete c.vars.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: async (c, input: { gridX: number; gridY: number }) => {
			const player = getControlledPlayer(c, c.conn.id);
			if (!player) return;
			const { gridX, gridY } = input;
			if (
				gridX < 0 ||
				gridX >= GRID_COLS ||
				gridY < 0 ||
				gridY >= GRID_COLS
			)
				return;
			const key = `${gridX},${gridY}`;
			if (c.vars.blocks[key] !== player.color) {
				c.vars.blocks[key] = player.color;
				await c.db.execute(
					"INSERT OR REPLACE INTO blocks (grid_x, grid_y, color) VALUES (?, ?, ?)",
					gridX,
					gridY,
					player.color,
				);
				broadcastSnapshot(c);
			}
		},
		removeBlock: async (c, input: { gridX: number; gridY: number }) => {
			if (!getConnectionMeta(c, c.conn.id)) return;
			const key = `${input.gridX},${input.gridY}`;
			if (key in c.vars.blocks) {
				delete c.vars.blocks[key];
				await c.db.execute(
					"DELETE FROM blocks WHERE grid_x = ? AND grid_y = ?",
					input.gridX,
					input.gridY,
				);
				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: { vars: ChunkVars }, playerId: string | null): Snapshot {
	const players: Snapshot["players"] = {};
	for (const [id, player] of Object.entries(c.vars.players)) {
		players[id] = {
			x: player.x,
			y: player.y,
			name: player.name,
			color: player.color,
		};
	}
	return {
		worldId: c.vars.worldId,
		chunkX: c.vars.chunkX,
		chunkY: c.vars.chunkY,
		chunkSize: CHUNK_SIZE,
		tick: c.vars.tick,
		selfPlayerId: playerId,
		players,
		blocks: { ...c.vars.blocks },
	};
}

function broadcastSnapshot(c: {
	vars: ChunkVars;
	conns: Map<
		string,
		{
			id: string;
			send: (eventName: "snapshot", data: Snapshot) => void;
		}
	>;
}) {
	for (const conn of c.conns.values()) {
		const meta = c.vars.connections[conn.id];
		const player = meta ? c.vars.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.
		}
	}
}

/**
 * Moves every player in the chunk each `TICK_MS` until the Actor starts
 * shutting down.
 */
async function runChunk(
	c: ActorContextOf<typeof openWorldChunk>,
): Promise<void> {
	const tick = interval(TICK_MS);
	let savedAt = Date.now();
	while (!c.aborted) {
		await tick();
		if (c.aborted) break;
		c.vars.tick += 1;

		const now = Date.now();
		for (const [id, player] of Object.entries(c.vars.players)) {
			// Drop restored players whose client did not come back.
			if (
				player.disconnectedAt !== null &&
				now - player.disconnectedAt > RECONNECT_GRACE_MS
			) {
				delete c.vars.players[id];
				continue;
			}

			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);

		// Ticks only change memory. Save to SQLite every
		// SNAPSHOT_INTERVAL_MS instead of on every tick.
		if (now - savedAt >= SNAPSHOT_INTERVAL_MS) {
			await saveChunk(c);
			savedAt = now;
		}
	}

	// Save once more when the Actor stops, so a clean restart loses nothing.
	await saveChunk(c);
}

/**
 * Writes the tick and every player position to SQLite in one transaction, so
 * a crash leaves either the previous save or this one, never part of both.
 * Blocks are not part of it, because each block action writes its own row.
 */
async function saveChunk(c: { vars: ChunkVars; db: RawAccess }) {
	const tick = c.vars.tick;
	const players: PlayerRow[] = Object.entries(c.vars.players).map(
		([playerId, player]) => ({
			player_id: playerId,
			name: player.name,
			color: player.color,
			x: player.x,
			y: player.y,
		}),
	);

	await c.db.transaction(
		async (tx) => {
			await tx.execute(
				"INSERT OR REPLACE INTO chunk (id, tick) VALUES (1, ?)",
				tick,
			);
			await tx.execute("DELETE FROM players");
			for (const player of players) {
				await tx.execute(
					"INSERT INTO players (player_id, name, color, x, y) VALUES (?, ?, ?, ?, ?)",
					player.player_id,
					player.name,
					player.color,
					player.x,
					player.y,
				);
			}
		},
		{ name: "save-chunk" },
	);
}

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: { vars: ChunkVars },
	connId: string,
): ConnectionMeta | null {
	return c.vars.connections[connId] ?? null;
}

function getControlledPlayer(
	c: { vars: ChunkVars },
	connId: string,
): PlayerEntry | null {
	const meta = getConnectionMeta(c, connId);
	if (!meta?.inChunk) return null;
	const player = c.vars.players[meta.playerId];
	if (!player || player.connId !== connId) return null;
	return player;
}
  • runChunk ticks 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.
  • onSleep and onDestroy wait for the loop to save and exit. onDestroy needs the wait because the Actor closes its database right after onDestroy returns.
  • Load grows with the number of chunks in use, not the size of the world. A chunk nobody has visited has no Actor, and a chunk nobody is connected to sleeps after the idle timeout and resumes from its last save on the next connection.
  • Ticks only change memory. Every SNAPSHOT_INTERVAL_MS (1 second), and once more when the Actor stops, saveChunk writes the tick and every player’s name, color, and position in one c.db.transaction, so a crash loses at most about a second of movement. The loop removes a restored player whose client hasn’t reconnected within RECONNECT_GRACE_MS (5 seconds).
  • 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.

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:

placeBlock: async (c, input: { gridX: number; gridY: number }) => {
	const player = getControlledPlayer(c, c.conn.id);
	if (!player) return;
	const { gridX, gridY } = input;
	if (
		gridX < 0 ||
		gridX >= GRID_COLS ||
		gridY < 0 ||
		gridY >= GRID_COLS
	)
		return;
	const key = `${gridX},${gridY}`;
	if (c.vars.blocks[key] !== player.color) {
		c.vars.blocks[key] = player.color;
		await c.db.execute(
			"INSERT OR REPLACE INTO blocks (grid_x, grid_y, color) VALUES (?, ?, ?)",
			gridX,
			gridY,
			player.color,
		);
		broadcastSnapshot(c);
	}
},
removeBlock: async (c, input: { gridX: number; gridY: number }) => {
	if (!getConnectionMeta(c, c.conn.id)) return;
	const key = `${input.gridX},${input.gridY}`;
	if (key in c.vars.blocks) {
		delete c.vars.blocks[key];
		await c.db.execute(
			"DELETE FROM blocks WHERE grid_x = ? AND grid_y = ?",
			input.gridX,
			input.gridY,
		);
		broadcastSnapshot(c);
	}
},
  • Blocks are saved in the blocks table, one row per grid cell with the color of the player who placed it. c.vars.blocks keeps the same data in memory as a map from "gridX,gridY" to color, so snapshots never read the database. Each change updates the map and writes the row, so a chunk keeps its blocks when it sleeps and wakes. Players are not written per action: the tick loop saves them, and 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.

Loading every block into memory works for a small grid per chunk. For bigger worlds, with many blocks or inventories, query the SQLite table by position instead of loading all of it on wake.

Register the Actors

Add the chunk Actor to the registry and start it:

  • 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:

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:

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:

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: run a physics engine in a world Actor’s tick loop.
  • Lifecycle: when onWake and onSleep run.
  • SQLite: store large chunk data with queries and indexes.
Edit this page Last updated November 10, 2026