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.
What we’ll build:
- An
openWorldChunkActor 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
npm add rivetkit@3.0.0-preview.1
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:
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;
}
- The key is
[worldId, chunkX, chunkY].createStatereads the coordinates fromc.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.
onConnectrecords theplayerIdfrom the connection params withinChunk: false, so a client can watch a chunk without standing in it.enterChunkadds the player at a spawn point clamped to the chunk, andleaveChunkremoves it.addPlayerandremovePlayerdo the same thing. setInputonly 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 ischunkX * 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 loop in run:
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;
}
runstarts when the Actor wakes and waits oninterval(TICK_MS)untilc.abortedis set. Every 100 ms it moves each player bySPEED, 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.
broadcastSnapshotloops overc.connsand callsconn.sendinstead ofc.broadcast, because each snapshot setsselfPlayerIdonly 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: (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:onDisconnectremoves them. placeBlockonly works for a player standing in this chunk and rejects cells outside the grid.removeBlockonly checks that the caller is connected, so a client watching from the next chunk can remove blocks. Check it the same way asplaceBlockif you don’t want that.- Both actions send a snapshot only when a block actually changed.
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 database and index it by position.
Register the Actors
Add the chunk Actor to the registry and start it:
import { setup } from "rivetkit";
import { arenaMatch } from "./arena/match.ts";
import { arenaMatchmaker } from "./arena/matchmaker.ts";
import { battleRoyaleMatch } from "./battle-royale/match.ts";
import { battleRoyaleMatchmaker } from "./battle-royale/matchmaker.ts";
import { idleLeaderboard } from "./idle/leaderboard.ts";
import { idleWorld } from "./idle/world.ts";
import { ioStyleMatch } from "./io-style/match.ts";
import { ioStyleMatchmaker } from "./io-style/matchmaker.ts";
import { openWorldChunk } from "./open-world/chunk.ts";
import { partyMatch } from "./party/match.ts";
import { partyMatchmaker } from "./party/matchmaker.ts";
import { physics2dWorld } from "./physics-2d/world.ts";
import { physics3dWorld } from "./physics-3d/world.ts";
import { rankedLeaderboard } from "./ranked/leaderboard.ts";
import { rankedMatch } from "./ranked/match.ts";
import { rankedMatchmaker } from "./ranked/matchmaker.ts";
import { rankedPlayer } from "./ranked/player.ts";
import { turnBasedMatch } from "./turn-based/match.ts";
import { turnBasedMatchmaker } from "./turn-based/matchmaker.ts";
export {
arenaMatch,
arenaMatchmaker,
battleRoyaleMatch,
battleRoyaleMatchmaker,
idleLeaderboard,
idleWorld,
ioStyleMatch,
ioStyleMatchmaker,
openWorldChunk,
partyMatch,
partyMatchmaker,
physics2dWorld,
physics3dWorld,
rankedLeaderboard,
rankedMatch,
rankedMatchmaker,
rankedPlayer,
turnBasedMatch,
turnBasedMatchmaker,
};
export const registry = setup({
use: {
arenaMatch,
arenaMatchmaker,
battleRoyaleMatch,
battleRoyaleMatchmaker,
idleLeaderboard,
idleWorld,
ioStyleMatchmaker,
ioStyleMatch,
openWorldChunk,
partyMatch,
partyMatchmaker,
physics2dWorld,
physics3dWorld,
rankedLeaderboard,
rankedMatch,
rankedMatchmaker,
rankedPlayer,
turnBasedMatch,
turnBasedMatchmaker,
},
});
openWorldChunkis 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 tohttp://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;
}
connectToChunkcallsgetOrCreate([WORLD_ID, String(cx), String(cy)]), which creates the chunk on first use. The sharedplayerIdlets each chunk match the connection to its player.updateAdjacentChunkskeeps 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
requestAnimationFramedraw 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
initiateTransferwhen 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. setPrimaryChunkcallsenterChunkon the new chunk and waits up to 1.5 seconds for a snapshot whoseselfPlayerIdis this player. Only then does it callleaveChunkon 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: how
runloops start and stop. - SQLite: store large chunk data with queries and indexes.