import { serveDir } from "@std/http/file-server"; import { CHUNK_HEIGHT } from "$/common/constants.ts"; import { BlockChange, ClientMessage, MAX_CHAT_LENGTH, MAX_NAME_LENGTH, PlayerInfo, ServerMessage, } from "$/common/protocol.ts"; const PORT = Number(Deno.env.get("PORT") ?? 8000); const WORLD_FILE = Deno.env.get("WORLD_FILE") ?? "world.json"; const STATIC_ROOT = "build"; const SAVE_INTERVAL_MS = 30_000; const MAX_MESSAGE_SIZE = 4096; // how far from a player a block can be changed, a bit more than the client's reach const MAX_REACH = 8; const BLOCK_ID_PATTERN = /^[a-z0-9_]+:[a-z0-9_]+$/; interface SavedWorld { seed: string; changes: BlockChange[]; } interface Client { socket: WebSocket; player?: PlayerInfo; } // the server doesnt generate terrain, clients do that from the seed // we only keep track of what players changed on top of it class ServerWorld { seed: string; changes = new Map(); dirty = false; constructor(seed: string) { this.seed = seed; } set_block(x: number, y: number, z: number, id: string) { this.changes.set(`${x},${y},${z}`, [x, y, z, id]); this.dirty = true; } static load(path: string): ServerWorld { try { const saved: SavedWorld = JSON.parse(Deno.readTextFileSync(path)); const world = new ServerWorld(saved.seed); for (const [x, y, z, id] of saved.changes) { world.changes.set(`${x},${y},${z}`, [x, y, z, id]); } console.log(`Loaded ${path} (${world.changes.size} block changes)`); return world; } catch (e) { if (!(e instanceof Deno.errors.NotFound)) { throw e; } const world = new ServerWorld(Deno.env.get("SEED") ?? crypto.randomUUID()); world.dirty = true; console.log(`Created new world with seed ${world.seed}`); return world; } } save(path: string) { if (!this.dirty) { return; } const saved: SavedWorld = { seed: this.seed, changes: [...this.changes.values()] }; // write then rename so a crash mid write doesnt eat the world Deno.writeTextFileSync(`${path}.tmp`, JSON.stringify(saved)); Deno.renameSync(`${path}.tmp`, path); this.dirty = false; } } const world = ServerWorld.load(WORLD_FILE); const clients = new Set(); function send(client: Client, message: ServerMessage) { if (client.socket.readyState === WebSocket.OPEN) { client.socket.send(JSON.stringify(message)); } } function broadcast(message: ServerMessage, except?: Client) { const data = JSON.stringify(message); for (const client of clients) { if (client !== except && client.player && client.socket.readyState === WebSocket.OPEN) { client.socket.send(data); } } } function is_number(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value); } function is_int(value: unknown): value is number { return Number.isInteger(value); } function clean_name(name: unknown): string { const cleaned = typeof name === "string" ? name.replace(/[^A-Za-z0-9_]/g, "").slice(0, MAX_NAME_LENGTH) : ""; const base = cleaned || `player${Math.floor(Math.random() * 10000)}`; // make it unique const taken = new Set([...clients].map((c) => c.player?.name)); let final = base; let i = 2; while (taken.has(final)) { final = `${base}${i}`; i += 1; } return final; } function handle_message(client: Client, message: ClientMessage) { if (!client.player) { if (message.type !== "hello") { return; } const player: PlayerInfo = { id: crypto.randomUUID(), name: clean_name(message.name), x: 0, y: 100, z: 0, yaw: 0, pitch: 0, }; send(client, { type: "welcome", id: player.id, seed: world.seed, players: [...clients].flatMap((c) => c.player ? [c.player] : []), changes: [...world.changes.values()], }); client.player = player; broadcast({ type: "player_join", player }, client); broadcast({ type: "chat", text: `${player.name} joined` }); console.log(`${player.name} joined (${clients.size} online)`); return; } const player = client.player; switch (message.type) { case "move": { const { x, y, z, yaw, pitch } = message; if (![x, y, z, yaw, pitch].every(is_number)) { return; } Object.assign(player, { x, y, z, yaw, pitch }); broadcast({ type: "player_move", id: player.id, x, y, z, yaw, pitch }, client); break; } case "set_block": { const { x, y, z, id } = message; if (!is_int(x) || !is_int(y) || !is_int(z) || y < 0 || y >= CHUNK_HEIGHT) { return; } if (typeof id !== "string" || !BLOCK_ID_PATTERN.test(id)) { return; } const dx = x + 0.5 - player.x; const dy = y + 0.5 - (player.y + 1.69); const dz = z + 0.5 - player.z; if (dx * dx + dy * dy + dz * dz > MAX_REACH * MAX_REACH) { return; } world.set_block(x, y, z, id); broadcast({ type: "set_block", x, y, z, id }, client); break; } case "chat": { if (typeof message.text !== "string") { return; } const text = message.text.trim().slice(0, MAX_CHAT_LENGTH); if (text.length === 0) { return; } console.log(`<${player.name}> ${text}`); broadcast({ type: "chat", from: player.name, text }); break; } } } function handle_socket(socket: WebSocket) { const client: Client = { socket }; socket.addEventListener("open", () => { clients.add(client); }); socket.addEventListener("message", (event) => { if (typeof event.data !== "string" || event.data.length > MAX_MESSAGE_SIZE) { return; } let message: ClientMessage; try { message = JSON.parse(event.data); } catch { return; } if (typeof message !== "object" || message === null) { return; } handle_message(client, message); }); socket.addEventListener("close", () => { clients.delete(client); if (client.player) { broadcast({ type: "player_leave", id: client.player.id }); broadcast({ type: "chat", text: `${client.player.name} left` }); console.log(`${client.player.name} left (${clients.size} online)`); } }); } setInterval(() => world.save(WORLD_FILE), SAVE_INTERVAL_MS); function shutdown() { console.log("Saving world..."); world.save(WORLD_FILE); Deno.exit(0); } Deno.addSignalListener("SIGINT", shutdown); if (Deno.build.os !== "windows") { Deno.addSignalListener("SIGTERM", shutdown); } Deno.serve({ port: PORT, onListen: ({ port }) => console.log(`bworld server on http://localhost:${port}/`) }, (req) => { const url = new URL(req.url); if (url.pathname === "/ws") { if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") { return new Response("Expected a websocket", { status: 426 }); } const { socket, response } = Deno.upgradeWebSocket(req); handle_socket(socket); return response; } if (url.pathname === "/") { return Response.redirect(new URL("/client/", url), 302); } return serveDir(req, { fsRoot: STATIC_ROOT, quiet: true }); });