From 1bef94ce0ce89e2087e54448dbdf310ab53d9410 Mon Sep 17 00:00:00 2001 From: Paula Date: Thu, 24 Sep 2026 19:11:19 -0300 Subject: [PATCH] Mods plan --- MODS.md | 944 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 944 insertions(+) create mode 100644 MODS.md diff --git a/MODS.md b/MODS.md new file mode 100644 index 0000000..15c1d5d --- /dev/null +++ b/MODS.md @@ -0,0 +1,944 @@ +# bworld mods specification + +Status: **draft, format_version 1**. Nothing here is implemented yet, see [Implementation plan](#implementation-plan). + +Mods add blocks, items, textures, recipes, world generation, game logic and GUIs to bworld. A mod is installed **on the +server**. Players who join download its client code, data and textures from the server automatically, so they don't +install anything themselves. Single player works the same way, with a server running inside the browser. + +This borrows from Minecraft Bedrock add-ons: data lives in JSON, blocks get behavior from named custom components, and +scripts use before/after events. Bedrock servers also push resource packs to joining players, and their server scripts +can show forms that the client draws (`@minecraft/server-ui`). bworld goes one step further and pushes client _scripts_ +too, since the client is already a web page. + +## Contents + +- [Overview](#overview) +- [Mod layout](#mod-layout) +- [manifest.json](#manifestjson) +- [Identifiers](#identifiers) +- [Textures](#textures) +- [Blocks](#blocks) +- [Items](#items) +- [Recipes](#recipes) +- [Server scripts](#server-scripts) +- [Client scripts](#client-scripts) +- [GUIs](#guis) +- [Mod channels](#mod-channels) +- [World generation](#world-generation) +- [Delivery to clients](#delivery-to-clients) +- [Single player](#single-player) +- [Security](#security) +- [Example mod](#example-mod) +- [Implementation plan](#implementation-plan) +- [Open questions](#open-questions) + +## Overview + +A mod can have up to three scripts, each running in a different place: + +| Script | Runs on | Sent to clients | Owns | +| ---------- | ------------------------------- | --------------- | ------------------------------------------------------------------------------ | +| `server` | the server, in a worker | **never** | game logic: block components, ticking, tile data, containers, commands, events | +| `client` | every client, main thread | yes | presentation: custom screens, HUD, keybinds | +| `worldgen` | server and client chunk workers | yes | terrain features, must be deterministic | + +The mod's JSON data and textures go to both sides. + +**The server is the authority.** Clients send what the player is trying to do ("break the block at x, y, z", "click slot +3"). The server checks it, runs mod logic and sends back what happened. For responsiveness, clients show the expected +result of breaking and placing immediately, and the server sends a correction if it disagrees. + +``` + server process browser +┌───────────────────────────────────────┐ ┌──────────────────────────────────┐ +│ host: http, websocket, files │ │ game client (rendering, input) │ +│ ┌───────────────────────────────────┐ │ ws │ + mod client scripts │ +│ │ game server worker │◄├──────────┤► (screens, HUD, keybinds) │ +│ │ world, inventories, tile data │ │ protocol │ │ +│ │ + mod server scripts │ │ │ chunk workers │ +│ └───────────────────────────────────┘ │ │ + mod worldgen scripts │ +│ chunk workers + mod worldgen scripts │ └──────────────────────────────────┘ +└───────────────────────────────────────┘ +``` + +Moving from the current setup to this is a big change, see the [Implementation plan](#implementation-plan). Right now +the server only relays block changes and doesn't know about blocks, inventories or terrain. + +## Mod layout + +``` +mods/ + copper_tools/ + manifest.json + blocks/*.json + items/*.json + recipes/*.json + worldgen/ores.json + textures/*.png + scripts/ + server.ts # game logic, stays on the server + client.ts # sent to players + worldgen.ts # sent to players, also runs on the server + shared/ # anything both sides import, bundled into each +``` + +Only `manifest.json` is required. Data folders can have subfolders, with one definition per file. + +## manifest.json + +```json +{ + "format_version": 1, + "id": "copper_tools", + "name": "Copper Tools", + "description": "Copper blocks, a copper pickaxe and a smelter with its own screen.", + "version": "1.0.0", + "authors": ["paula"], + "game_version": ">=0.1.0", + "dependencies": [ + { "id": "more_ores", "version": "^2.0.0" } + ], + "scripts": { + "server": "scripts/server.ts", + "client": "scripts/client.ts", + "worldgen": "scripts/worldgen.ts" + } +} +``` + +| Field | Required | Meaning | +| ------------------ | -------- | ----------------------------------------------------------------------------------------- | +| `format_version` | yes | Version of this spec the mod targets. Loaders reject versions they don't know. | +| `id` | yes | The mod's namespace, see [Identifiers](#identifiers). Must match the folder name. | +| `name` | yes | Display name, shown to players when joining. | +| `description` | no | One or two sentences. | +| `version` | yes | Semver of the mod itself. | +| `authors` | no | List of names. | +| `game_version` | no | Semver range of bworld versions the mod works with. | +| `dependencies` | no | Other mods by `id`, with a semver range. Missing or mismatched dependencies fail loading. | +| `scripts.server` | no | Server entry. Never sent to clients. | +| `scripts.client` | no | Client entry, sent to every player. | +| `scripts.worldgen` | no | Worldgen entry, sent to every player and also run on the server. | + +Scripts can be `.js` or `.ts`. The build bundles each entry separately into one ES module. Code imported by both the +server and client entries is copied into both bundles, so **don't import secrets into shared code**. Anything the client +bundle imports is visible to players. + +## Identifiers + +Everything a mod registers has an id of the form `namespace:name`: + +- `namespace` is the mod's `id`. It must match `^[a-z0-9_]+$`, be at most 32 characters, and not be `bworld`, which is + the base game. +- `name` must match `^[a-z0-9_]+$`. +- A mod only registers ids in its own namespace: blocks, items, components, screens, channels and HUD elements. It can + _refer to_ any id. +- Registering an id that already exists is a load error. + +Blocks get a numeric id when they register, and chunk data stores those numbers. Numeric ids are **local to one running +program**, so the server and each client can number blocks differently. Saves and the network always use string ids. + +## Textures + +- Every `.png` in `textures/` becomes a texture with id `:`. Subfolders are joined with + `_`, so `textures/ores/tin.png` becomes `:ores_tin`. +- Textures are 16×16. The server's build puts every installed mod's textures into one atlas with the base game's, and + clients download that atlas instead of using their own. +- A missing texture shows the magenta and black checker and logs a warning. It isn't a load error. + +## Blocks + +`blocks/copper_block.json`: + +```json +{ + "format_version": 1, + "block": { + "id": "copper_tools:copper_block", + "textures": "copper_tools:copper_block", + "collision": true, + "mining": { "toughness": 5, "tool": "pickaxe", "requires_tool": true }, + "drops": "copper_tools:copper_block", + "item": true, + "components": { + "copper_tools:oxidizes": { "seconds": 600, "into": "copper_tools:oxidized_copper_block" } + } + } +} +``` + +| Field | Default | Maps to `BlockRegistry` | Meaning | +| ---------------------- | ----------- | ----------------------- | --------------------------------------------------------------- | +| `id` | required | `id` | The block's id. | +| `textures` | required | `textures` | One texture id, `{ top, bottom, side }` or `{ front, side }`. | +| `transparent` | `false` | `transparent` | Drawn in the transparent pass, neighbors' faces stay visible. | +| `alpha` | `1` | `alpha` | Opacity for transparent blocks. | +| `collision` | `true` | `has_collision` | Whether entities collide with it. | +| `mining.toughness` | unbreakable | `toughness` | Seconds to break by hand. A matching tool is 2× faster. | +| `mining.tool` | none | `tool_to_break` | Tool type that speeds it up, like `pickaxe`, `axe` or `shovel`. | +| `mining.requires_tool` | `false` | `requires_tool` | Only drops when broken with `mining.tool`. | +| `drops` | nothing | `drop_table` | Item id given when broken. | +| `item` | `true` | `register_block_item` | Also register an item that places this block, with the same id. | +| `states` | none | `states` | Up to 16 bits of named state, like `hoed_dirt` has now. | +| `components` | none | the `on_*` hooks | Custom components with parameters, handled by server scripts. | + +The client needs this data too (for meshing, mining time and collision), so it's sent to every player. It can't contain +functions. + +Block states work the same as today: `[{ "name": "facing", "bits": 2, "default": 0 }]`. They can't change textures yet, +because the mesher ignores them. `variants` is reserved for that. + +## Items + +`items/copper_pickaxe.json`: + +```json +{ + "format_version": 1, + "item": { + "id": "copper_tools:copper_pickaxe", + "texture": "copper_tools:copper_pickaxe", + "tool": "pickaxe", + "max_stack": 1, + "lore": "Better than wood, worse than iron.", + "components": { "copper_tools:durability": { "max": 250 } } + } +} +``` + +| Field | Default | Maps to `ItemRegistry` | Meaning | +| ------------ | -------- | ------------------------ | -------------------------------------------------- | +| `id` | required | registry key | The item's id. | +| `texture` | required | `texture_id` | Texture id. | +| `tool` | none | `tool_type` | Tool type used by blocks' `mining.tool`. | +| `places` | none | `block_id` | Block placed when used on a block face. | +| `max_stack` | `64` | new | Largest stack size. | +| `lore` | none | `get_lore` | Static tooltip text. | +| `components` | none | `on_create` / `get_lore` | Custom item components, handled by server scripts. | + +Item stacks can carry `data`, a JSON value set by the server (like the watering can's water level). It's synced to +clients as part of the container it's in, so client screens can show it. + +## Recipes + +`recipes/smelt_copper_block.json`: + +```json +{ + "format_version": 1, + "recipe": { + "type": "furnace", + "input": "copper_tools:copper_block", + "output": { "id": "bworld:copper_ingot", "count": 9 }, + "cook_time": 400 + } +} +``` + +| `type` | Fields | Notes | +| --------- | --------------------------------------------------------------- | ----------------------------------------------- | +| `furnace` | `input` item id, `output` `{ id, count }`, `cook_time` in ticks | Same as the table in `client/blocks/furnace.ts` | +| `fuel` | `item` id, `burn_time` in ticks | Same as `FUEL_VALUES` in `furnace.ts` | + +Two recipes with the same `input` are a load error. Server scripts can also register recipe types of their own through +`ctx.recipes`. Crafting recipes are reserved until the game has crafting. + +## Server scripts + +`scripts.server` exports `setup`, called once when the server starts: + +```ts +import type { ServerContext } from "bworld/server"; + +export function setup(ctx: ServerContext) { + // register components, commands, containers, event handlers +} +``` + +Server scripts run in the game server worker. They get the mod API and standard JavaScript, and **no Deno, Node or DOM +APIs**. That lets the same script run on a dedicated server and in the browser for [single player](#single-player). They +have no file or network access; persistent state goes through `ctx.storage`. + +```ts +interface ServerContext { + mod: { id: string; version: string }; + components: ComponentRegistry; // only during setup + commands: CommandRegistry; // only during setup + events: { before: ServerBeforeEvents; after: ServerAfterEvents }; + system: System; + world: ServerWorld; + players: PlayerList; + containers: ContainerApi; + recipes: RecipeApi; + ui: ServerUi; // see GUIs + net: ServerNet; // see Mod channels + storage: ModStorage; + log(...args: unknown[]): void; +} +``` + +### Block and item components + +Blocks and items get behavior from components registered here: + +```ts +ctx.components.register_block("copper_tools:oxidizes", { + on_create(block) { + block.data = { age: 0 }; + }, + on_second(block, params, dt) { + block.data.age += dt; + if (block.data.age >= params.seconds) { + ctx.world.set_block(block.x, block.y, block.z, params.into); + } + }, +}); +``` + +| Handler | Called when | Return value | +| ------------------------------------ | ----------------------------------------------------- | ------------------------------------------ | +| `on_create(block, params)` | The block is placed or set. | ignored | +| `on_break(block, params, player?)` | The block is broken or replaced. | ignored | +| `on_click(block, params, player)` | A player left clicks it. | ignored | +| `on_interact(block, params, player)` | A player right clicks it. | `true` if handled, so no block gets placed | +| `on_tick(block, params, dt)` | Every tick (20 per second) while its chunk is loaded. | ignored | +| `on_second(block, params, dt)` | Every second while its chunk is loaded. | ignored | + +`block` is `{ id, x, y, z, data }`. `data` is tile data: any JSON value, `undefined` until a handler sets it, saved with +the world and **never sent to clients**. To show tile data to a player, open a screen with it (see [GUIs](#guis)). +`on_tick` and `on_second` only run for blocks that have tile data, like today. + +When a block lists several components, each handler runs in the listed order. `on_interact` counts as handled if any +component returns `true`. + +Item components use +`ctx.components.register_item(id, { on_create(item, params), get_lore(item, params), on_use(item, +params, player) })`. + +### Events + +**Before events** fire before something happens. Handlers can read and change the event, or set `cancel = true` to stop +it. They can't change the world themselves; use the after event or `system.run_timeout(fn, 0)` for that. **After +events** fire once it has happened. + +| Event | before | after | Payload | +| ---------------- | ------ | ----- | -------------------------------------------------------- | +| `block_break` | ✓ | ✓ | `player`, `block`, `item` | +| `block_place` | ✓ | ✓ | `player`, `block`, `face`, `item` | +| `block_interact` | ✓ | ✓ | `player`, `block`, `item` | +| `chat_send` | ✓ | ✓ | `player`, `message` (before events can change `message`) | +| `player_join` | | ✓ | `player` | +| `player_leave` | | ✓ | `player` | +| `server_start` | | ✓ | none | +| `tick` | | ✓ | `dt` in seconds, 20 times per second | + +These fire for **every** player's actions, because they all go through the server. When a before event cancels a break +or place, the server tells that player's client to undo what it already showed. + +### World, players, system, storage + +```ts +interface ServerWorld { + get_block(x: number, y: number, z: number): string | undefined; // undefined when the chunk isn't loaded + set_block(x: number, y: number, z: number, id: string): boolean; // runs on_break / on_create, synced to everyone + get_state(x: number, y: number, z: number, name: string): number | undefined; + set_state(x: number, y: number, z: number, name: string, value: number): boolean; + get_block_data(x: number, y: number, z: number): T | undefined; + is_loaded(x: number, z: number): boolean; + readonly seed: string; +} + +interface Player { + readonly id: string; + readonly name: string; + readonly position: { x: number; y: number; z: number }; + readonly inventory: Container; // 36 slots, hotbar is 0-8 + readonly selected_slot: number; + give_item(id: string, count?: number, data?: unknown): void; + send_message(text: string): void; + teleport(x: number, y: number, z: number): void; +} + +interface PlayerList { + all(): Player[]; + get(id: string): Player | undefined; + by_name(name: string): Player | undefined; +} + +interface System { + run_timeout(fn: () => void, ticks: number): number; + run_interval(fn: () => void, ticks: number): number; + clear_run(handle: number): void; + readonly current_tick: number; +} + +// small key value store per mod, saved with the world +interface ModStorage { + get(key: string): T | undefined; + set(key: string, value: unknown): void; // JSON values only + delete(key: string): void; +} +``` + +The server loads chunks around every player (its simulation distance) and generates them itself with the same generator +as the clients. Blocks in unloaded chunks can't be read or changed, and don't tick. + +### Commands + +```ts +ctx.commands.register("heal", { + description: "Heal yourself", + usage: "/heal [amount]", + run(args, player) { + player.send_message(`healed ${args[0] ?? "all"}`); + }, +}); +``` + +Commands run on the server when a player types `/name` in chat. Two mods using the same name, or a mod using a base game +name like `give`, is a load error. `/copper_tools:heal` always works as the unambiguous form. + +## Client scripts + +`scripts.client` exports `setup`, called after the client has downloaded the server's mods and before the world appears: + +```ts +import type { ClientContext } from "bworld/client"; + +export function setup(ctx: ClientContext) { + // register screens, HUD elements, keybinds, channel handlers +} +``` + +```ts +interface ClientContext { + mod: { id: string; version: string }; + ui: ClientUi; // see GUIs + hud: HudRegistry; + input: { bind(id: string, default_key: KeyCode, on_press: () => void): void }; + net: ClientNet; // see Mod channels + player: { readonly name: string; readonly position: { x: number; y: number; z: number } }; + world: { get_block(x: number, y: number, z: number): string | undefined }; // read only, what the client sees + log(...args: unknown[]): void; +} +``` + +Client scripts are for presentation. They can't change the world or inventories; they ask the server over a +[mod channel](#mod-channels). Anything they show should be treated as a view of the server's state. Everything in a +client script is visible to players and can be changed by them, so it must not be trusted for anything that matters. + +Keybinds registered with `ctx.input.bind` get an id (`copper_tools:open_smelter`) so a future controls menu can rebind +them. They only fire while no screen is open. + +## GUIs + +There are three ways to show a GUI. They're listed from least code to most. Pick the first one that's enough. + +### 1. Forms (server only) + +Simple dialogs defined entirely by server code, like Bedrock's `@minecraft/server-ui`. The client draws them with the +game's UI style. No client script is needed. + +```ts +const result = await ctx.ui.action_form(player, { + title: "Teleporter", + body: "Where to?", + buttons: [ + { text: "Spawn", icon: "bworld:compass" }, + { text: "Home" }, + ], +}); +if (!result.canceled && result.selection === 0) { + player.teleport(0, 100, 0); +} + +const settings = await ctx.ui.modal_form(player, { + title: "Smelter settings", + fields: [ + { type: "toggle", label: "Auto-eject", default: true }, + { type: "slider", label: "Speed", min: 1, max: 4, step: 1, default: 1 }, + { type: "dropdown", label: "Output", options: ["Chest", "Ground"] }, + { type: "text", label: "Name", placeholder: "smelter", max_length: 32 }, + ], +}); +// settings.values = [true, 3, 0, "smelter"], checked against the fields before your code sees them +``` + +| Form | Shows | Resolves with | +| -------------- | ------------------------------ | ------------------------------------------------------- | +| `message_form` | title, body, two buttons | `{ canceled, selection: 0 \| 1 }` | +| `action_form` | title, body, a list of buttons | `{ canceled, selection: number }` | +| `modal_form` | title, input fields | `{ canceled, values: (boolean \| number \| string)[] }` | + +The promise resolves with `canceled: true` when the player closes the form, disconnects, or another screen replaces it. +The engine checks every response against the form's definition (types, ranges, option counts, text length) before your +code sees it. + +### 2. Container screens (server only) + +For inventories and machines. The server owns the container's contents and syncs them to every player viewing it. Clicks +go to the server, which applies them. The chest and furnace would be rebuilt this way instead of their hand-written +`GuiChest` / `GuiFurnace` classes. + +```ts +ctx.components.register_block("copper_tools:smelter", { + on_create(block) { + block.data = { container: ctx.containers.create(3).id, progress: 0 }; + }, + on_interact(block, _params, player) { + const container = ctx.containers.get(block.data.container)!; + const screen = ctx.ui.open_container(player, { + title: "Smelter", + layout: [ + { slot: 0, x: 3, y: 0, filter: "smeltable" }, + { slot: 1, x: 3, y: 2, filter: (item) => ctx.recipes.is_fuel(item.id) }, + { slot: 2, x: 5, y: 1, output_only: true }, + ], + container, + player_inventory: true, + bars: [{ id: "progress", x: 4, y: 1, texture: "bworld:arrow" }], + }); + screen.set_property("progress", block.data.progress); + return true; + }, +}); +``` + +- `x` / `y` are in slot units on a grid. The client scales and centers the screen, and draws the player's inventory + below it when `player_inventory` is true. +- `filter` is a function `(item) => boolean` or one of the built-in filters `"smeltable"` (has a furnace recipe) and + `"fuel"`. It and `output_only` run **on the server**, so players can't put the wrong items in by editing their client. +- `bars` are progress bars filled from 0 to 1 by `screen.set_property(id, value)`. Properties can also be shown as text + with `labels: [{ x, y, property }]`. +- The screen handle has `set_property`, `close()` and `on_close(fn)`. Changes to the container (from scripts, hoppers, + other players) show up for everyone viewing it. + +```ts +interface ContainerApi { + create(size: number): Container; // saved with the world + get(id: string): Container | undefined; + delete(id: string): void; +} + +interface Container { + readonly id: string; + readonly size: number; + get(slot: number): ItemStack | undefined; + set(slot: number, item: ItemStack | undefined): void; + add(item: ItemStack): ItemStack | undefined; // returns what didn't fit + on_change(fn: (slot: number) => void): () => void; +} +``` + +### 3. Custom screens (client code) + +For anything forms and containers can't do: maps, skill trees, minigames, custom layouts. The client script registers a +screen class, and the server opens it with some props: + +```ts +// client.ts +import type { ClientContext, Graphics, ModScreen } from "bworld/client"; + +export function setup(ctx: ClientContext) { + ctx.ui.register_screen("copper_tools:smelter_stats", (props) => new SmelterStats(ctx, props)); +} + +class SmelterStats implements ModScreen { + constructor(private ctx: ClientContext, private props: { total_smelted: number; top: [string, number][] }) {} + + on_render(g: Graphics) { + g.panel(g.width / 2 - 200, g.height / 2 - 150, 400, 300); + g.text(`Smelted: ${this.props.total_smelted}`, g.width / 2 - 180, g.height / 2 - 130, { scale: 2 }); + this.props.top.forEach(([id, count], i) => { + g.item(id, g.width / 2 - 180, g.height / 2 - 90 + i * 60); + g.text(`${count}`, g.width / 2 - 110, g.height / 2 - 75 + i * 60); + }); + if (g.button("Reset", g.width / 2 - 60, g.height / 2 + 100, 120, 32)) { + this.ctx.net.send("copper_tools:reset_stats", {}); + } + } + + // the server sent new props for this screen + on_props(props: SmelterStats["props"]) { + this.props = props; + } +} +``` + +```ts +// server.ts +const screen = ctx.ui.open_screen(player, "copper_tools:smelter_stats", { total_smelted: 42, top: [] }); +screen.update({ total_smelted: 43, top: [] }); // calls on_props on the client +screen.on_close(() => ctx.log("closed")); +``` + +```ts +interface ModScreen { + on_open?(): void; + on_tick?(dt: number): void; + on_render(g: Graphics): void; + on_props?(props: unknown): void; + on_close?(): void; + // return true to keep the screen open when escape is pressed + on_escape?(): boolean; +} + +// immediate mode, like the debug ui, styled with assets/sprites/ui.png +interface Graphics { + readonly width: number; + readonly height: number; + readonly mouse: { x: number; y: number; down: boolean; pressed: boolean }; + rect(x: number, y: number, w: number, h: number, color?: [number, number, number, number]): void; + panel(x: number, y: number, w: number, h: number): void; // nine slice background + text(text: string, x: number, y: number, options?: { scale?: number; color?: number[] }): void; + measure_text(text: string, scale?: number): number; + texture(id: string, x: number, y: number, w: number, h: number): void; // any atlas texture + item(id: string, x: number, y: number, count?: number): void; + clip(x: number, y: number, w: number, h: number, draw: () => void): void; + button(label: string, x: number, y: number, w: number, h: number): boolean; // true on the frame it's clicked + text_input(id: string, x: number, y: number, w: number): string; + slider(id: string, x: number, y: number, w: number, min: number, max: number): number; + // server synced slots, same behavior as container screens + slots(container: string, layout: { slot: number; x: number; y: number }[], x: number, y: number): void; + key_pressed(key: KeyCode): boolean; +} +``` + +Custom screens can also show server containers. The server passes them in when opening: +`ctx.ui.open_screen(player, id, props, { containers: { input: container } })`, and the screen draws them with +`g.slots("input", layout, x, y)`. Clicks still go to the server and follow the container rules, so a custom screen can't +bypass `filter`. + +A client script can also open its own screens with `ctx.ui.open(id, props)`, for things like a settings page that +doesn't involve the server. + +### HUD + +```ts +ctx.hud.register("copper_tools:heat", { + on_render(g) { + g.text(`Heat: ${heat}`, 8, 8); + }, +}); +``` + +HUD elements draw every frame after the world, under open screens. They get their data from mod channels. + +## Mod channels + +Client and server parts of a mod talk over named channels. Messages are JSON. + +```ts +// server.ts +ctx.net.on("copper_tools:reset_stats", (player, _data) => { + if (!is_admin(player)) return; + stats.clear(); +}); +ctx.net.send(player, "copper_tools:heat", { value: 12 }); +ctx.net.broadcast("copper_tools:announcement", { text: "the smelter exploded" }); + +// client.ts +ctx.net.on("copper_tools:heat", (data) => { + heat = data.value; +}); +ctx.net.send("copper_tools:reset_stats", {}); +``` + +- Channel ids are in the mod's namespace. A mod can listen to other mods' channels but only send on its own. +- A message is at most 16 KB of JSON. Each player can send at most 60 mod messages per second in total; the server drops + anything over that and logs it. +- **Everything a client sends is untrusted.** Check the player's permission, check every field's type and range, and + never use client data as an item id or amount without validating it. +- Messages to a channel nobody listens to are dropped, with a warning in development builds. +- On the wire this is one protocol message, `{ type: "mod", channel, data }`, in both directions. + +## World generation + +Chunks are generated from the world seed by chunk workers on **both** the server and every client, and all of them must +produce identical terrain. So worldgen scripts must be **deterministic**. + +`worldgen/ores.json` uses the same fields as the `ORES` table in `client/generation.ts`: + +```json +{ + "format_version": 1, + "ores": [ + { "id": "copper_tools:rich_copper_ore", "min_y": 5, "max_y": 40, "scale": 0.05, "threshold": 0.72 } + ] +} +``` + +`scripts.worldgen` registers features, which run on each chunk after the base terrain and trees: + +```ts +import type { WorldgenContext } from "bworld/worldgen"; + +export function setup(gen: WorldgenContext) { + gen.register_feature("copper_tools:boulders", (chunk) => { + if (chunk.rng.next() > 0.1) return; + const x = chunk.x * 16 + Math.floor(chunk.rng.next() * 16); + const z = chunk.z * 16 + Math.floor(chunk.rng.next() * 16); + chunk.set_block(x, chunk.height_at(x, z) + 1, z, "bworld:stone"); + }); +} +``` + +```ts +interface FeatureChunk { + x: number; // chunk coordinates + z: number; + seed: string; + rng: { next(): number }; // seeded from the seed, chunk and feature id + noise_2d(name: string): (x: number, z: number) => number; // cached per seed and name + noise_3d(name: string): (x: number, y: number, z: number) => number; + height_at(x: number, z: number): number; // surface height, inside this chunk only + biome_at(x: number, z: number): string; + get_block(x: number, y: number, z: number): string | undefined; // inside this chunk only + set_block(x: number, y: number, z: number, id: string): void; // up to one chunk away, like trees +} +``` + +- **No `Math.random`, `Date`, `performance.now` or network access.** Use `chunk.rng` and the noise helpers. +- Worldgen can't use server or client APIs, and generated blocks don't run `on_create`. +- Each feature's `rng` is seeded with its own id, so adding or removing another mod's feature doesn't change what this + one generates. + +## Delivery to clients + +### Build + +`deno task build` builds each mod in `mods/` into two places: + +``` +build/mods/// # public, served to players + manifest.json + data.json # all blocks, items, recipes and ores merged + client.js + worldgen.js +server_mods/// # private, outside the static root, never served + server.js +``` + +`` is a content hash of the mod's public files, so URLs never change content. The server sends them with +`Cache-Control: immutable`, and players only download a mod again when it changes. Textures from every mod go into the +server's atlas, which gets a hash-named URL the same way. + +### Joining + +``` +client server + │ hello { name, protocol } │ + ├────────────────────────────────────────►│ + │ welcome { seed, atlas, mods[] } │ mods[i] = { id, name, version, hash, + │◄────────────────────────────────────────┤ data, client?, worldgen? } (urls) + │ │ + │ [cross-origin: confirm screen] │ + │ fetch data, atlas, scripts │ + │ check hashes, register data, │ + │ run client setup(), start workers │ + │ │ + │ ready │ + ├────────────────────────────────────────►│ + │ join { players, changes, inventory } │ player_join fires on the server here + │◄────────────────────────────────────────┤ +``` + +- The client registers the base game, then each mod's data **in the order the server lists them**. +- If any download, hash check or `setup` fails, the client disconnects and shows which mod failed. It never joins with + some mods missing. +- The `protocol` in `hello` is the game's protocol version. A mismatch is rejected before any mod code is downloaded. +- Leaving a server reloads the page, so one server's mod code never stays loaded while playing on another. + +## Single player + +Single player runs the same game server inside the browser, like Minecraft's integrated server: + +- The game server core (world, inventories, tile data, mod server scripts) is written against a small host interface for + **transport** and **storage**, with no Deno or DOM APIs. +- On a dedicated server, the host is `server/main.ts`: WebSocket transport, files for storage. The core runs in a Deno + worker. +- In single player, the host is the page: `postMessage` transport, IndexedDB for storage. The core runs in a web worker, + and `server.js` bundles are loaded from `server_mods/`, which the local build serves only to this page. + +The client uses the same protocol either way, so a mod written and tested in single player behaves the same on a +dedicated server. + +## Security + +**Client scripts.** Mod client code runs in the game page with the page's full access. How much that matters depends on +where the page came from: + +- **Page served by the game server** (the default): that server already chose every line of JavaScript on the page, so + running its mods doesn't trust it any more than loading the page did. +- **Page from one origin, game server on another** (`?server=`): mod code from that server runs with _the page's_ + origin, including its local storage. The client shows the server's mod list and asks before loading anything, and + remembers the answer per server and mod hash. The server needs CORS headers on `build/mods/`. +- The hash check confirms the files are the ones the server listed. It doesn't protect against a malicious server. + +**Server scripts.** They run in a worker with **no Deno permissions** (Deno worker permissions; currently needs +`--unstable-worker-options`). They can't read files, open connections or run programs. Everything they need goes through +the mod API. Installing a server mod still means trusting it with the game world and everything players send. + +**Players.** Clients are untrusted. The server checks reach and the item being held for every break, place and interact, +applies container rules on the server, validates form responses, and rate-limits mod channels. Server script code and +tile data are never sent to clients. + +## Example mod + +A smelter block with a container screen, a stats screen written in client code, and a channel between them. + +`mods/copper_tools/manifest.json` + +```json +{ + "format_version": 1, + "id": "copper_tools", + "name": "Copper Tools", + "version": "1.0.0", + "scripts": { "server": "scripts/server.ts", "client": "scripts/client.ts" } +} +``` + +`mods/copper_tools/blocks/smelter.json` + +```json +{ + "format_version": 1, + "block": { + "id": "copper_tools:smelter", + "textures": { "front": "copper_tools:smelter_front", "side": "bworld:stone" }, + "mining": { "toughness": 5, "tool": "pickaxe", "requires_tool": true }, + "drops": "copper_tools:smelter", + "components": { "copper_tools:smelter": { "speed": 2 } } + } +} +``` + +`mods/copper_tools/scripts/server.ts` + +```ts +import type { ServerContext } from "bworld/server"; + +interface SmelterData { + container: string; + progress: number; + smelted: number; +} + +export function setup(ctx: ServerContext) { + ctx.components.register_block("copper_tools:smelter", { + on_create(block) { + block.data = { container: ctx.containers.create(3).id, progress: 0, smelted: 0 } satisfies SmelterData; + }, + on_break(block) { + ctx.containers.delete((block.data as SmelterData).container); + }, + on_interact(block, _params, player) { + const data = block.data as SmelterData; + ctx.ui.open_container(player, { + title: "Smelter", + container: ctx.containers.get(data.container)!, + layout: [ + { slot: 0, x: 3, y: 0, filter: "smeltable" }, + { slot: 1, x: 3, y: 2, filter: "fuel" }, + { slot: 2, x: 5, y: 1, output_only: true }, + ], + player_inventory: true, + bars: [{ id: "progress", x: 4, y: 1, texture: "bworld:arrow" }], + }).set_property("progress", data.progress); + return true; + }, + on_tick(block, params) { + const data = block.data as SmelterData; + // ... smelt using ctx.recipes, data.progress += params.speed, data.smelted += 1 when done + }, + }); + + ctx.commands.register("smelterstats", { + description: "Show stats for the smelter you're looking at", + usage: "/smelterstats", + run(_args, player) { + ctx.ui.open_screen(player, "copper_tools:smelter_stats", { smelted: count_all_smelted() }); + }, + }); + + ctx.net.on("copper_tools:reset_stats", (player) => { + if (player.name !== ctx.storage.get("owner")) return; + reset_all_smelted(); + }); +} +``` + +`mods/copper_tools/scripts/client.ts` + +```ts +import type { ClientContext, Graphics, ModScreen } from "bworld/client"; + +export function setup(ctx: ClientContext) { + ctx.ui.register_screen("copper_tools:smelter_stats", (props) => new SmelterStats(ctx, props)); +} + +class SmelterStats implements ModScreen { + constructor(private ctx: ClientContext, private props: { smelted: number }) {} + + on_render(g: Graphics) { + const x = g.width / 2 - 150; + const y = g.height / 2 - 80; + g.panel(x, y, 300, 160); + g.text(`Smelted: ${this.props.smelted}`, x + 20, y + 20, { scale: 2 }); + if (g.button("Reset", x + 90, y + 100, 120, 32)) { + this.ctx.net.send("copper_tools:reset_stats", {}); + } + } + + on_props(props: { smelted: number }) { + this.props = props; + } +} +``` + +## Implementation plan + +None of this exists yet. Each step keeps the game working: + +1. **Game server core.** Move `client/generation.ts` to `common/` (it already only needs constants and the rng package) + and have the server generate terrain. Pull world state, chunks, tile data and player inventories into an + environment-free module behind a transport and storage interface. Run it in a Deno worker from `server/main.ts`. +2. **Server authority.** Change the protocol from "here's the block I changed" to intents (`break_block`, `place_block`, + `interact`, `select_slot`, `container_click`). The client keeps showing breaks and places immediately and accepts + corrections. Move inventories, drops and `/give` to the server. Replace `GuiChest` / `GuiFurnace` with server-synced + containers. +3. **Integrated server.** Run the same core in a web worker for single player, with IndexedDB storage. +4. **Mod loader and build.** Discover mods, check manifests, sort by dependencies, turn JSON into registry entries, + build the combined atlas (textures are currently all named `bworld:`), bundle scripts per side, and write + hashed output to `build/mods/` and `server_mods/`. +5. **Delivery.** Split `welcome` into `welcome` / `ready` / `join`. Clients download, verify and run mods before + joining. Add the confirm screen for cross-origin servers and CORS headers on the server. +6. **Server scripts.** Components, events, commands, system, storage and `ctx.recipes`. Move `FURNACE_RECIPES` and + `FUEL_VALUES` into the recipe registry. +7. **GUIs.** Forms, then container screens (rebuild chest and furnace with them), then custom screens, the `Graphics` + API (built on the existing renderer and debug UI widgets) and the HUD. +8. **Mod channels** and keybinds. +9. **Worldgen mods.** Worldgen URLs and mod ores go into the chunk worker `init` message. Workers must finish importing + before generating anything. + +Steps 1–3 are engine work every multiplayer feature needs, with or without mods. Mods could start with data only (step 4 +plus data in step 5) before scripts exist. + +## Open questions + +- **Chunk delivery.** Clients generate terrain from the seed, which needs deterministic worldgen and trusts clients to + run the same generator. Sending chunks from the server instead (like Minecraft) removes both problems but costs + bandwidth, roughly 5–15 KB per chunk compressed. It could be worth it once the server generates terrain anyway. +- **Client-only mods.** Should players be able to install their own client mods (minimaps, UI tweaks) that work on any + server? They'd need a separate `"side": "client"` kind of mod that can't add content, and servers might want to forbid + them. +- **Client-side prediction for mods.** Custom block components only run on the server, so interacting with a mod block + always waits one round trip. Optional client-side component handlers for prediction could fix that later. +- **Overriding base game content.** Replacing `bworld:` blocks is forbidden for now. An `"extends"` field that adds + components to an existing block (for example to `bworld:grass`) is a possible middle ground. +- **Screen scaling.** The GUI currently works in raw canvas pixels (`SLOT_SIZE` is 54). `Graphics` should probably use a + UI scale the player can change, with screens laid out in scaled units. +- **Entities.** The game's only entity is the player, so mob mods are out of scope until the ECS has more entity types. +- **Hot reload.** Restarting the server and reconnecting is the version 1 answer.