# bworld mods specification Status: **draft, format_version 1**, partly built. The [Implementation plan](#implementation-plan) lists what works today and what's planned, and sections about planned features say so. Mods add blocks, items, textures, recipes, world generation, game logic and GUIs to bworld, and can change how the game itself works. 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. bworld is always played on a server; there is no single player mode. bworld's modding takes from three places: - **Minetest: the game is mods.** The engine is small, and everything that makes bworld _bworld_ (its blocks, items, crafting, inventory screen, HUD, commands and terrain) lives in `mods/bworld`, written against the same API as any other mod. Mods can change each other's content, including the base game's. - **Minecraft Bedrock add-ons: easy to start.** Most content is JSON, blocks and items get behavior from named components, scripts use before/after events and server-driven forms, and players get everything from the server. - **Minecraft Java with Fabric: no ceiling.** A mod that needs to can reach into the engine itself and hook any of its methods, on the server and on the client, like Fabric's mixins. ## Contents - [Overview](#overview) - [Loading](#loading) - [Creating a mod](#creating-a-mod) - [Mod layout](#mod-layout) - [manifest.json](#manifestjson) - [Identifiers](#identifiers) - Data: [Textures](#textures), [Blocks](#blocks), [Block models](#block-models), [Items](#items), [Recipes](#recipes), [Overrides](#overrides) - Game API: [Common scripts](#common-scripts), [Server scripts](#server-scripts), [Client scripts](#client-scripts), [GUIs](#guis), [Mod channels](#mod-channels), [World generation](#world-generation) - [Engine access](#engine-access) - [Delivery to clients](#delivery-to-clients) - [Security](#security) - [Example mod](#example-mod) - [The engine and the base game](#the-engine-and-the-base-game) - [Implementation plan](#implementation-plan) - [Open questions](#open-questions) ## Overview A mod is built from up to three layers. Most mods only need the first one or two. | Layer | Like | What a mod writes | Stability | | --------------------------------- | --------------------------------- | --------------------------------------------------------------------------------- | -------------------------------------------------- | | **Data** | Bedrock behavior packs | JSON in `blocks/`, `items/`, `models/`, `recipes/`, `overrides/`, `worldgen/`, and textures | Versioned by `format_version` | | **Game API** | Minetest's Lua API, Bedrock's Script API | TypeScript against `bworld/common`, `bworld/server`, `bworld/client` and `bworld/worldgen` | Stable, only breaks with the game's major version | | **[Engine access](#engine-access)** | Fabric's mixins | `import { engine, hook } from "bworld/engine"`, opted into in the manifest | None, tied to one game version | Data is shorthand for the Game API: loading `blocks/copper_block.json` does exactly what `ctx.blocks.register(...)` with the same object does in a common script. So anything a JSON file can do, code can do too, for content that's easier to generate (every color of wool) or that depends on other mods. A mod can have up to four scripts, each running in a different place: | Script | Runs on | Sent to clients | Owns | | ---------- | ---------------------------------------------- | --------------- | ------------------------------------------------------------------------------------ | | `common` | the server and every client, before the others | yes | content: registering blocks, items, models and recipes, and changing other mods' content | | `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 and features, must be deterministic | **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 common and client │ │ │ game server worker │◄├──────────┤► scripts │ │ │ world, inventories, tile data │ │ protocol │ │ │ │ + mod common and server scripts │ │ │ chunk workers │ │ └───────────────────────────────────┘ │ │ + mod worldgen scripts │ │ chunk workers + mod worldgen scripts │ └──────────────────────────────────┘ └───────────────────────────────────────┘ ``` ## Loading The server and every client load mods the same way, in the same order: dependencies first, otherwise alphabetical. The server sends its order to clients, and they follow it. 1. **Content**, one mod at a time: the mod's JSON data is registered, then its `common` script's `setup` runs. So a common script sees its own data and everything from the mods before it, and can change it. 2. **Freeze.** The registries close: blocks get their numeric ids, block items are created and recipes are indexed. Registering or changing content after this throws. 3. **Scripts.** `server` scripts' `setup` runs on the server, `client` scripts' on each client, and `worldgen` scripts in every chunk worker. These can read every registry but not change them. 4. The server starts ticking, or the client sends `ready` and joins. **Content has to come out the same on every side.** Common scripts run on the server and on every client, and all of them must end up with exactly the same blocks, items and recipes: no `Math.random`, dates, or anything that differs between the server and a client. After the freeze each side hashes its registries (every id and its definition), and the client sends the hash with `ready`. When it doesn't match the server's, the player is turned away with a message naming the first thing that differs, instead of playing with mismatched blocks. _Planned, phase 1._ ## Creating a mod ```sh deno task new-mod copper_tools "Copper Tools" # copies templates/mod to mods/copper_tools deno task check-mods # validates every mod in mods/ ``` The template has one of everything: a block with a custom component, an item, a shaped recipe, textures, and a script for each side. `check-mods` checks manifests and data files against this spec, checks that every id, item and texture a mod refers to exists (and that it depends on the mods those come from), and typechecks scripts against the API types in `common/mod_api/`, which scripts import as `bworld/common`, `bworld/server`, `bworld/client`, `bworld/worldgen` and, with engine access, `bworld/engine`. Folders in `mods/` starting with `_` or `.` are ignored. `deno task build` packs every mod in `mods/` into a `.bmod` in `server_mods/` and fails if any has errors; `deno task server` then loads every `.bmod` there. `deno task pack-mod mods/copper_tools` packs one mod into a file to share, see [Mod files](#mod-files). ## Mod layout ``` mods/ copper_tools/ manifest.json deno.json # optional: the mod is its own deno project, see Mod files blocks/*.json models/*.json items/*.json recipes/*.json overrides/*.json # changes to other mods' content worldgen/ores.json textures/*.png scripts/ common.ts # content, runs on both sides 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 several scripts 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": "bworld", "version": "*" }, { "id": "more_ores", "version": "^2.0.0" } ], "scripts": { "common": "scripts/common.ts", "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. Mods with `engine_access` need one. | | `dependencies` | no | Other mods by `id`, with a semver range. Missing or mismatched dependencies fail loading. A mod can only change content of mods it depends on. | | `scripts.common` | no | Content entry, run on the server and sent to every player. _Planned, phase 1._ | | `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. | | `engine_access` | no | `true` lets the mod's scripts import `bworld/engine`, see [Engine access](#engine-access). _Planned, phase 3._ | | `credits` | no | A markdown file in the mod, shown on the Credits screen. For asset licenses and thanks. | Scripts can be `.js` or `.ts`. The build bundles each entry separately into one ES module. Code imported by several entries is copied into each bundle, so **don't import secrets into shared code**. Anything the common, client or worldgen bundles import 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_]+$` and be at most 32 characters. - Two namespaces are reserved. `bworld` belongs to the base game mod shipped in `mods/bworld` (see [The engine and the base game](#the-engine-and-the-base-game)). `engine` belongs to the engine itself, for things every game needs, like the block-breaking cracks and the built-in block models. The one exception is `bworld:air`: it's the engine's empty block, but it keeps that id because every save already contains it. - `name` must match `^[a-z0-9_]+$`. - A mod only registers ids in its own namespace: blocks, items, models, components, recipe types, screens, channels and HUD elements. It can _refer to_ any id, and [change](#overrides) other mods' content. - Registering an id that already exists is a load error. Changing an existing one is what overrides are for. 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. They go in the mod's `.bmod`, and clients put every mod's textures into one atlas when they join. - A missing texture shows the magenta and black checker (`engine:missing`) and logs a warning. It isn't a load error. - The engine's own textures use the `engine` namespace: `engine:missing` and the breaking cracks `engine:break_0` to `engine:break_8`. They come from `assets/`, not from a mod. ## 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 }`. With another `model`, the model's texture variables, like `{ "crop": "..." }`. | | `model` | `engine:cube` | `model` | The [block model](#block-models). | | `variants` | none | `variants` | A different `model`, `textures` or `y` rotation for some states, see [block models](#block-models). | | `render_layer` | `solid` | `render_layer` | `solid`, `cutout` (texels fully opaque or fully clear, like leaves) or `translucent` (blended, like water and glass). Non-solid blocks don't hide their neighbors' faces. | | `cull_same` | see meaning | `cull_same` | Hide faces between two of this block. Defaults to `true` for translucent blocks, `false` otherwise. | | `light_emission` | `0` | `light_emission` | Light level 0-15 it gives off, like a torch (14). | | `light_opacity` | see meaning | `light_opacity` | How much light passing through it loses, 0-15. Defaults to `15` (blocks all light) for solid blocks and `0` otherwise. Leaves and water use `1`. | | `alpha` | `1` | `alpha` | Opacity for translucent 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 dropped on the ground when broken, for players to pick up. | | `item` | `true` | `register_block_item` | Also register an item that places this block, with the same id. | | `interactive` | `false` | `interactive` | Right clicking it does something (opens a screen), so clients don't guess it places a block. | | `replaceable` | `false` | new | Placing a block into it replaces it, like water. | | `states` | none | `states` | Up to 16 bits of named state, like `hoed_dirt` has now. | | `components` | none | `components` | 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 are declared like `[{ "name": "facing", "bits": 2, "default": 0 }]`, at most 16 bits in total. A block starts with its defaults when it's placed or generated, and server scripts read and change them with `ctx.world.get_state` / `set_state`. States are saved with the world and synced to players. `variants` changes how the block looks with them. ## Block models A block's shape comes from its model, `engine:cube` unless it sets `model`. The engine has three: | Model | Texture variables | Shape | | -------------- | -------------------------------------------------- | --------------------------------------------- | | `engine:cube` | one texture, `{ top, bottom, side }` or `{ front, side }` | A full block. | | `engine:cross` | `cross` | Two crossed planes, like flowers and saplings. | | `engine:crop` | `crop` | Four planes in a # shape, like wheat. | Blocks that aren't full cubes should use the `cutout` (or `translucent`) render layer, because `solid` blocks hide their neighbors' faces. This is what minecraft's `"render_type": "minecraft:cutout"` does. They usually want `"collision": false` too, and show as a flat sprite of their first texture in inventories. `blocks/wheat.json` picks a texture per growth stage with `variants`. The keys are conditions on the block's states, `"age=3"` or `"age=3,facing=1"` (all have to match), and the first variant that matches replaces the block's `model`, `textures` or turns it by `y` (0, 90, 180 or 270 degrees, clockwise seen from above): ```json { "format_version": 1, "block": { "id": "bworld:wheat", "model": "engine:crop", "textures": { "crop": "bworld:wheat_stage_0" }, "variants": { "age=0": { "textures": { "crop": "bworld:wheat_stage_0" } }, "age=1": { "textures": { "crop": "bworld:wheat_stage_1" } } }, "render_layer": "cutout", "collision": false, "states": [{ "name": "age", "bits": 3, "default": 0 }] } } ``` Mods add their own models in `models/`, in minecraft's block model format: boxes in pixels (0-16 is the block) with a texture per face. `models/slab.json`: ```json { "format_version": 1, "model": { "id": "copper_tools:slab", "textures": { "top": "#side", "bottom": "#side" }, "elements": [{ "from": [0, 0, 0], "to": [16, 8, 16], "faces": { "top": { "texture": "#top" }, "bottom": { "texture": "#bottom", "cullface": "bottom" }, "north": { "texture": "#side", "cullface": "north" }, "south": { "texture": "#side", "cullface": "south" }, "west": { "texture": "#side", "cullface": "west" }, "east": { "texture": "#side", "cullface": "east" } } }] } } ``` | Field | Meaning | | ---------------------------- | ---------------------------------------------------------------------------------------------- | | `id` | The model's id, in the mod's namespace. | | `textures` | Defaults for texture variables. Values are texture ids or other variables (`"#side"`). | | `elements[].from`, `to` | Opposite corners of the box, `[x, y, z]` in pixels. | | `elements[].rotation` | `{ origin, axis, angle, rescale }`: turns the box around `origin` on `x`, `y` or `z` by -45, -22.5, 0, 22.5 or 45 degrees. `rescale` stretches it back to the block's width. | | `elements[].shade` | Darker faces on the sides and bottom, `true` by default. Plants turn it off. | | `elements[].faces` | `top`, `bottom`, `north`, `south`, `west` and `east`. Faces are only drawn from the front, so a flat plane needs one face each way. | | `faces.*.texture` | A variable (`"#side"`, filled in by the block's `textures`) or a texture id. | | `faces.*.uv` | `[u1, v1, u2, v2]`, the part of the texture in pixels. Defaults to the part the face covers. | | `faces.*.cullface` | Hidden when the neighbor on that side is a solid block. Only for faces on the block's edge. | Faces flat against the block's edge get smooth lighting and ambient occlusion like a full block's. Anything inside the block is lit evenly with the block's own light. ## 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 | | --------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | | `shaped` | `pattern` rows (space is empty), `key` letter to item id, `result` `{ id, count }` | Used by the crafting grid, fits anywhere in it | | `furnace` | `input` item id, `output` `{ id, count }`, `cook_time` in ticks | Used by the furnace | | `fuel` | `item` id, `burn_time` in ticks | Used by the furnace | | `smithing` | `tool` item id, `material` `{ id, count }`, optional `addition` item id, `result` item id | Used by the smithing table: upgrades the tool, keeping its data. Uses up the tool, `count` of the material and the addition | A smithing recipe, `recipes/smithing_stone_pickaxe.json`: ```json { "format_version": 1, "recipe": { "type": "smithing", "tool": "bworld:wood_pickaxe", "material": { "id": "bworld:stone", "count": 5 }, "result": "bworld:stone_pickaxe" } } ``` A shaped recipe, for the crafting grid in the player's inventory screen: ```json { "format_version": 1, "recipe": { "type": "shaped", "pattern": ["PPP", "P P", "PPP"], "key": { "P": "bworld:planks" }, "result": { "id": "bworld:chest", "count": 1 } } } ``` Two furnace recipes with the same `input`, two smithing recipes with the same tool, material and addition, or two shaped recipes with the same pattern and key, are a load error. Mods can add recipe types of their own for their machines, see [Common scripts](#common-scripts). ## Overrides A mod can change content other mods registered, like Minetest's `override_item`. The JSON form covers the common cases, and a [common script](#common-scripts) can do the same with `override` and `extend`. _Planned, phase 2._ `overrides/grass_weeds.json`: ```json { "format_version": 1, "override": { "block": "bworld:grass", "set": { "mining": { "toughness": 1, "tool": "shovel" } }, "add_components": { "farming_plus:weeds": { "chance": 0.01 } } } } ``` | Field | Meaning | | ------------------- | ---------------------------------------------------------------------------------------------------- | | `block` or `item` | The id to change. | | `set` | Fields to replace, with the same names as in the block or item JSON. Each listed field is replaced whole, so `mining` above replaces all of the old `mining`. | | `add_components` | Components to add. One the block or item already has gets these params instead. | | `remove_components` | Component ids to take away. | A recipe override removes recipes, so a mod can replace them with its own: ```json { "format_version": 1, "override": { "remove_recipes": { "type": "shaped", "result": "bworld:chest" } } } ``` `remove_recipes` matches recipes whose fields equal the ones given (`result` matches the result's id). Rules: - A mod can only change content of mods it lists in `dependencies`, so it always loads after them. `check-mods` makes this an error. - Changes apply in load order, so when two mods change the same field the later one wins. `check-mods` warns about it. - Ids never change, and blocks and items can't be removed, because saves refer to them. To make something unobtainable, remove its recipes and drops. - Overrides are content like everything else: they're applied on the server and every client, and are part of the registry hash. ## Common scripts `scripts.common` exports `setup`, called on the server and on every client while content loads, right after the mod's own JSON is registered (see [Loading](#loading)). _Planned, phase 1._ ```ts import type { CommonContext } from "bworld/common"; const COLORS = ["white", "red", "green", "blue", "black"]; export function setup(ctx: CommonContext) { for (const color of COLORS) { ctx.blocks.register({ id: `wool:${color}_wool`, textures: `wool:${color}_wool`, mining: { toughness: 1 }, drops: `wool:${color}_wool`, }); ctx.recipes.register({ type: "shaped", pattern: ["D", "W"], key: { D: `wool:${color}_dye`, W: "wool:white_wool" }, result: { id: `wool:${color}_wool`, count: 1 }, }); } // wool burns in the base game's furnace for (const color of COLORS) { ctx.recipes.register({ type: "fuel", item: `wool:${color}_wool`, burn_time: 100 }); } // every leaf block any mod registered drops sticks sometimes for (const id of ctx.blocks.ids().filter((id) => id.endsWith("_leaves"))) { ctx.blocks.extend(id, { components: { "wool:stick_drop": { chance: 0.05 } } }); } } ``` ```ts interface CommonContext { mod: { id: string; version: string }; blocks: ContentRegistry; items: ContentRegistry; models: ContentRegistry; recipes: RecipeRegistry; log(...args: unknown[]): void; } // T is the same shape as the JSON file's content, checked the same way interface ContentRegistry { register(definition: T): void; get(id: string): Readonly | undefined; // what's registered so far, by any mod ids(): string[]; override(id: string, fields: Partial): void; // like "set" in an override file extend(id: string, change: { components?: Record; remove_components?: string[] }): void; } interface RecipeRegistry { register(recipe: RecipeJson): void; remove(match: (recipe: RecipeJson) => boolean): number; // how many it removed list(type: string): readonly RecipeJson[]; // a new recipe type: its recipes are checked with validate, and scripts read them with list register_type(id: string, type: { validate(recipe: unknown): string[] }): void; } ``` - Definitions are checked exactly like JSON files, and a problem is a load error naming the mod. - Common scripts only run during loading. They can't keep state for later, and can't reach the world, players or anything else: that's what server and client scripts are for. They get the same `ctx` shape on both sides on purpose, so they can't accidentally register different things. - Recipe types are ids like everything else. `shaped`, `furnace`, `fuel` and `smithing` are short for `bworld:shaped` and so on, since the base game's crafting grid, furnace and smithing table use them. A mod's machine registers its own type (`copper_tools:alloy`) and its server script reads the recipes with `ctx.recipes.list("copper_tools:alloy")`. ## 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 with **no Deno permissions** (see [Security](#security)). They get the mod API and standard JavaScript, but no file, network or subprocess access; persistent state goes through `ctx.storage`. `ctx` is the Game API for the server. Content is already registered and frozen by now (see [Loading](#loading)), so server scripts read registries but don't change them. ```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; items: ItemApi; 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 starts hitting 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) near a player. | ignored | | `on_second(block, params, dt)` | Every second near a player. | 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, so a component that ticks from the start sets `block.data` in `on_create`. They run within 6 chunks of a player. `dt` is in seconds: 0.05 for `on_tick`, 1 for `on_second`. The server runs a fixed 20 ticks per second. When a tick takes too long, the next ones run back to back until it has caught up, so game time keeps pace with real time. When it's more than ten ticks behind it skips the rest and logs a warning. `/tps` shows how it's doing. Timers (`ctx.system`) count these ticks. 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) })`. `on_use` runs when a player right clicks while holding the item, both at nothing and at a block. At a block, the block's own interaction comes first, and an item with `on_use` is used instead of being placed. ### 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; drop_item(x: number, y: number, z: number, item: ItemStack): void; // pops out of the block, like drops is_loaded(x: number, z: number): boolean; readonly seed: string; readonly time: number; // world time, see below set_time(time: number): void; // synced to every player } 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; readonly held_item: ItemStack | undefined; // what doesn't fit in the inventory drops at their feet 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; } ``` **World time** counts ticks since 06:00 of day 1 and never wraps around. A day (06:00 to 18:00) is 36000 ticks, 30 minutes, and a night (18:00 to 06:00) is 12000 ticks, 10 minutes, so in game hours are shorter at night. Dusk is the first minute of the night and dawn the last, so it's bright all day. `common/time.ts` has `clock`, `format_clock`, `is_day` and `daylight` for turning it into something readable. Client scripts can read it as `ctx.world.time`, and players can use `/time`, `/time set ` and `/time add `. 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 one of the engine's (`give`, `time` and `tps`, until phase 5 moves `give` and `time` into `mods/bworld`), 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; } ``` Only `ctx.player`, `ctx.world` and `ctx.log` work so far, the rest is phase 4. 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) _Planned, phase 4._ 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 base game's chest and furnace are built this way, see `mods/bworld/scripts/server.ts`. ```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, { container, 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 }, ], bars: [{ x: 4, y: 1, value: "progress", max: "progress_max", direction: "right", empty_texture: "bworld:arrow_empty", full_texture: "bworld:arrow_full", }], }); screen.set_property("progress", block.data.progress); screen.set_property("progress_max", 200); return true; }, on_break(block) { ctx.containers.delete(block.data.container); }, }); ``` - The screen is drawn below the player's inventory and hotbar. `x` / `y` are in slot units in the screen's own area and can be fractional. `rows` sets its height, by default it fits the slots and bars. - `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. `output_only` slots can only be taken from, all at once, like the furnace result. Their `on_take(item)` runs when a player takes from one, before they get it: return `false` to stop them, or use up the inputs there so they're gone in the same step (the smithing table does this). - `bars` are progress bars filled with `value / max`, both names of properties set with `screen.set_property(id, n)`. `direction` is `"up"` (like the furnace's fire) or `"right"` (like its arrow). Properties are synced when they change, so a component can update them every tick. - The screen handle has `player`, `open`, `set_property`, `close()` and `on_close(fn)`. It closes when the player closes it, leaves, or opens another screen, and when its container is deleted. Changes to the container (from scripts, other players) show up for everyone viewing it. - Containers are saved with the world until they're deleted, so a block that has one deletes it in `on_break`. ```ts interface ContainerApi { create(size: number): Container; // 1 to 256 slots, saved with the world get(id: string): Container | undefined; delete(id: string): void; // closes screens showing it } 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; } interface ItemApi { exists(id: string): boolean; max_stack(id: string): number; } ``` Items handed to scripts are live: setting `count` changes the stack, and a stack counted down to 0 is gone. ### 3. Custom screens (client code) _Planned, phase 4._ 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 _Planned, phase 4._ ```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 _Planned, phase 4._ 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**. Each chunk is generated in three passes: 1. **Terrain.** One terrain generator fills in the ground, water and trees. 2. **Ores.** Every mod's `worldgen/ores.json`, in load order. 3. **Features.** Every mod's registered features, in load order. The world is 256 blocks tall and the sea is at y 64 (`CHUNK_HEIGHT` and `SEA_LEVEL` in `common/constants.ts`). The base overworld lives in `common/worldgen/` until phase 6 moves it into `mods/bworld`. It works like Minecraft 1.18+ and the Terralith datapack: continentalness, erosion and weirdness noises feed nested splines that give every column a height, jaggedness and roughness, a 3D density around that height is sampled on a coarse grid and interpolated, and caves are cut out of it. On top of that come Terralith-style shapes: terraced plateaus, shattered hills, river valleys and gorges, jagged peaks and rare sky islands. Its biome ids (`biome_at`) are the keys of `BIOMES` in `common/worldgen/overworld.ts`, like `bworld:yosemite_cliffs` or `bworld:skylands`. Trees (`common/worldgen/trees.ts`) are spread out like Poisson disk sampling, never closer than 4 blocks, and each biome sets how many grow and which kinds. `deno run -A tools/worldgen_preview.ts [seed]` renders it to PNG files. ### Terrain generators A world uses exactly one terrain generator, registered by a worldgen script. The base game's will be `bworld:overworld` (phase 6); until then `register_terrain` throws and the engine's own overworld is used. Which one a world uses is saved with the world. A new world uses the only one installed, or the server's `TERRAIN` setting when there are several. A server with no terrain generator refuses to start. ```ts export function setup(gen: WorldgenContext) { gen.register_terrain("bworld:overworld", (chunk) => { const height_noise = chunk.noise_2d("height"); for (let x = 0; x < 16; x++) { for (let z = 0; z < 16; z++) { const wx = chunk.x * 16 + x; const wz = chunk.z * 16 + z; const height = Math.floor((height_noise(wx * 0.01, wz * 0.01) + 1) * 15 + 50); for (let y = 0; y <= height; y++) { chunk.set_block(wx, y, wz, y === height ? "bworld:grass" : "bworld:stone"); } chunk.set_height(wx, wz, height); chunk.set_biome(wx, wz, "bworld:plains"); } } }); } ``` A terrain generator gets a `TerrainChunk`: a `FeatureChunk` (below) plus `set_height(x, z, height)` and `set_biome(x, z, biome)`, which feed `height_at` / `biome_at` in later passes. Its `set_block` can write up to one chunk away, like trees. Writes into another chunk only fill air, so the result doesn't depend on which chunk generates first. Seeding is exact, so a generator ported from the current code produces the same terrain: - `noise_2d(name)` is `create_noise_2d(new Alea(seed + "_" + name))` from `@paulaboks/rng`, and `noise_3d` is the same with `create_noise_3d`. Both are cached per seed and name. - A terrain generator's `rng` is `new Alea(seed + "_chunk_" + x + "_" + z)`. - A feature's `rng` is `new Alea(seed + "_feature_" + feature_id + "_" + x + "_" + z)`. ### Ores `worldgen/ores.json` uses the same fields as the `BASE_ORES` table in `common/generation.ts`: ```json { "format_version": 1, "ores": [ { "id": "copper_tools:rich_copper_ore", "replaces": "bworld:stone", "min_y": 5, "max_y": 40, "scale": 0.05, "threshold": 0.72 } ] } ``` Each block in the chunk that is `replaces` and between `min_y` and `max_y` becomes the first ore whose `noise_3d(id)` at `(x, y, z) * scale` is above `threshold`. ### Features `scripts.worldgen` registers features, which run on each chunk after terrain and ores: ```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. ## Engine access For what the Game API doesn't cover yet: a new kind of rendering, different player physics, a change to how lighting spreads, an entity type. Like Fabric's mixins, a mod can reach the engine's own classes and hook their methods, on the server and on the client. It's powerful and unstable at once, so a mod has to ask for it. _Planned, phase 3._ ```json { "engine_access": true, "game_version": "~0.4.0" } ``` ```ts // client.ts: an outline around every dropped item import type { ClientContext } from "bworld/client"; import { engine, hook } from "bworld/engine"; export function setup(ctx: ClientContext) { hook(engine.client.LevelRenderer.prototype, "render_entities", { after(renderer, [level, camera_entity, partial_tick]) { for (const entity of level.entities.values()) { if (entity instanceof engine.client.ItemEntity) draw_outline(entity, partial_tick); } }, }); } ``` ```ts // server.ts: broken blocks drop twice import { engine, hook } from "bworld/engine"; export function setup() { hook(engine.server.GameServer.prototype, "pop_item", { after(game, [x, y, z, stack], entity) { game.spawn_item(entity.x, entity.y, entity.z, stack.clone()); return entity; }, }); } ``` ```ts // client.ts: everything falls at half speed. physics is an object so its functions can be hooked, see below import { engine, hook } from "bworld/engine"; export function setup() { hook(engine.common.physics, "move_body", { before(_physics, args) { args[1] /= 2; // gravity }, }); } ``` ### What `bworld/engine` gives - `engine` holds the engine's modules as they run on that side: `engine.common` (registries, constants, inventory, physics, generation, the protocol types) everywhere, `engine.server` (`GameServer`, `ServerWorld`, `ModRuntime`, the running `game`) in server scripts, and `engine.client` (`Client`, `ClientLevel`, the renderer, GUI classes, the running `client`) in client scripts. Worldgen scripts get `engine.common` only. - They're the live classes and objects the game uses, not copies: the engine puts them on the global object at startup, and `bworld/engine` in a mod's bundle is a small shim that reads them. So a hook on a prototype changes every instance the engine already made. - The types are the engine's own source, so `check-mods` typechecks a mod against exactly the engine it will run on, and an engine change that breaks a mod shows up there first. ### Hooks ```ts function hook(target: T, method: K, handlers: { // runs first. change args in place, or return { result } to skip the method and everything after before?(self: T, args: Parameters): { result: ReturnType } | void; // wraps the method, calling original (or not) replace?(self: T, args: Parameters, original: T[K]): ReturnType; // runs last, and returns the result the caller gets after?(self: T, args: Parameters, result: ReturnType): ReturnType; // lower runs earlier, the default is 1000. ties go in load order priority?: number; }): () => void; // unhook ``` - Hooks on a prototype apply to every instance, hooks on one object only to it. - Several mods can hook the same method. `before` hooks run in priority order, `replace` hooks nest (the first one's `original` is the next one's wrapper, the last one's is the engine's method), and `after` hooks run in priority order, each getting the previous one's result. - A hook that throws is logged under its mod and skipped, like event handlers. The method still runs. - Functions exported from a module can't be hooked, because a module's exports can't be reassigned. The engine puts functions mods may want to change on plain objects (like `engine.common.physics`) and calls them through those objects itself, so a hook changes what the engine calls. Classes' methods are hookable on their prototype. - JavaScript's `#private` members can't be hooked or read. The engine keeps things that mods should be able to change as normal members, and `#private` for things with rules that must hold (like a container's slots). Making something hookable is an ordinary engine change, and asking for one is how the Game API grows. - Hooks reach one JavaScript realm: the game server worker for server scripts, the page for client scripts, and one chunk worker for worldgen scripts. Each chunk worker runs its own copy of the engine, so a hook in a client script doesn't change meshing; a worldgen script with engine access can. ### Rules - **It isn't a stable API.** Any game update can rename or change what a hook targets. A mod with `engine_access` must set `game_version` to a range within one minor version (like `~0.4.0`). Loading it on another version is a load error naming the mod, instead of a crash somewhere later. - **Prefer the Game API.** When it can do something, it keeps working across updates. Engine access is for what it can't do yet, and a mod that hooks the same thing as many others is a sign it should become part of the Game API. - Without `engine_access`, importing `bworld/engine` is a `check-mods` error and a load error. - See [Security](#security) for what it means for players and servers. ## Delivery to clients ### Mod files A mod is shipped as a `.bmod` file: the mod, built, in one zip. Servers load every `.bmod` in their `server_mods/` folder, so installing a mod is dropping its file there and restarting. ```sh deno task pack-mod mods/copper_tools # writes copper_tools.bmod deno task build # packs every mod in mods/ into server_mods/.bmod ``` ``` copper_tools.bmod manifest.json # the mod's manifest, with scripts and credits pointing into the zip data.json # every block, model, item, recipe and ore, checked when it was packed scripts/server.js # each script bundled into one module scripts/client.js scripts/worldgen.js textures/.png # the texture copper_tools: credits.md ``` - A mod is a Deno project. When its folder has a `deno.json`, its scripts are bundled with it, so its own imports and npm or JSR packages work and end up in the bundle. Mods without one use the `deno.json` of the folder the build runs in, like the ones in this repository. - Packing checks the mod first, like `check-mods`, and refuses to pack one with errors. - Packing the same mod twice gives the same bytes, so the file's hash only changes when the mod does. - `deno task build` leaves other `.bmod` files in `server_mods/` alone, so mods from elsewhere can sit next to the ones it builds. When the server starts it opens every `.bmod`, checks it again (it may come from anyone): its manifest and data, its textures, the ids it uses from other mods, and that its dependencies are there. A problem in any mod stops the server from starting, saying which mod and what's wrong, so the game never runs with some mods missing. Then it sorts them so dependencies load first. Players get each mod as a `.bmod` too, **without its server script**: the server makes a copy without `scripts/server.js` and serves it at `/mods/.bmod`, named by its hash so it can be cached forever. The code is in `common/bmod.ts` (reading and writing), `tools/pack_mod.ts` and `server/load_bmods.ts`. The engine's own files (the client, its UI sprites and font, and the `engine:` textures) aren't in any mod. They're in `build/`, which the server serves as it is. ### Joining ``` client server │ hello { name, protocol } │ ├────────────────────────────────────────►│ a different protocol gets rejected { reason } │ welcome { protocol, seed, mods[] } │ mods[i] = { id, name, version, sha256, file, size } │◄────────────────────────────────────────┤ file is the .bmod's path on the server │ │ │ [cross-origin: confirm screen] │ │ download every .bmod, check each one's │ │ sha256, build the texture atlas, load │ │ content in the listed order, run │ │ client setup(), start chunk workers │ │ │ │ ready { registry_hash } │ a different hash gets rejected { reason } ├────────────────────────────────────────►│ │ join { id, name, players, entities, │ the player is created here, player_join fires, │ changes, spawn, selected_slot } │ and their inventory follows as container messages │◄────────────────────────────────────────┤ ``` - The client registers each mod's data **in the order the server lists them**. - Every `.bmod` is checked against its SHA-256 before it's opened, and scripts are imported from the checked bytes (as `blob:` URLs), so what runs is exactly what was checked. The client code is in `client/handshake.ts` and `client/mods.ts`. - The client builds the texture atlas itself, from the engine's textures and the ones in the `.bmod` files (`client/atlas.ts`). The server never needs to decode an image. - 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 (`PROTOCOL_VERSION` in `common/protocol.ts`). A mismatch is rejected before anything is downloaded. - Until `ready`, the connection isn't a player: other players don't see it, and anything it sends besides `ready` is ignored. A client that doesn't send `ready` within 60 seconds is rejected. - `.bmod` files are served with `Access-Control-Allow-Origin: *` and cached for a year, since their paths change whenever their content does. The engine's assets are served with `Access-Control-Allow-Origin: *` too, for pages from other origins. - Leaving a server reloads the page, so one server's mod code never stays loaded while playing on another. ## 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** (a different server typed in the title screen, or `?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 its `.bmod` downloads and assets, which it sends. - The hash check confirms the files are the ones the server listed. It doesn't protect against a malicious server. **Engine access.** A server mod with engine access controls the game server worker completely, but the worker still has no Deno permissions, so it can't reach files or the network any more than other server scripts. A client mod with engine access changes nothing about what it _can_ reach, since client scripts already run with the page's full access, but it can change how the game behaves for players in ways the Game API can't. The confirm screen for other servers lists which mods use engine access. **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. **Server scripts stay on the server.** Players get each mod's `.bmod` without its server script, so a mod can keep anti-cheat logic or anything else private there. **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", "dependencies": [{ "id": "bworld", "version": "*" }], "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, { 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 }, ], bars: [{ x: 4, y: 1, value: "progress", max: "progress_max", direction: "right", empty_texture: "bworld:arrow_empty", full_texture: "bworld:arrow_full", }], }).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; } } ``` ## The engine and the base game Like a Minetest game, bworld is the engine plus one mod, `mods/bworld`, written against the same API as any other mod. This is how the API gets tested for real: if the base game can't be written as a mod, the API is missing something, and other mods would hit the same wall. It also means there's one way content works, so mods can do anything the base game does, and change any of it. **The engine** keeps what every game built on it needs: rendering, meshing, lighting and the sky; physics, player movement and input; networking, saving, the game server and its loop; registries, the mod loader and the Game API; containers, slot click rules, screens and the GUI toolkit; item entities; chat; world time; `/tps`; `bworld:air`; and the engine assets in `assets/` (UI sprites, font, player sprite, and the `engine:` textures and block models). **`mods/bworld`** has everything else: | What | Where it is now | Status | | ---------------------------------------------------- | ----------------------------------- | ----------------------- | | Blocks, items, models, recipes, textures, credits | `mods/bworld` JSON and `textures/` | Done | | Hoeing, chest, furnace, smithing table, crops, watering can | `mods/bworld/scripts/server.ts` | Done | | Recipe types `shaped`, `furnace`, `fuel`, `smithing` | engine (`common/mod_loader.ts`, `server/game/crafting.ts`) | Phase 5 | | The player's inventory screen and crafting grid | engine (`client/gui/gui_player_inventory.ts`, `server/game/crafting.ts`) | Phase 5 | | The hotbar and crosshair | engine (`client/gui/hud.ts`) | Phase 5 | | `/give` and `/time` | engine (`server/game/game_server.ts`) | Phase 5 | | Mining: tool speed, `requires_tool`, drops | engine (`client/game_mode.ts`, `server/game/game_server.ts`) | Phase 5 | | Day and night lengths, sky colors | engine (`common/time.ts`, `client/rendering/game_renderer.ts`) | Phase 5 | | Where new players spawn | engine (`server/game/world.ts`) | Phase 5 | | Terrain, biomes, trees and ores | engine (`common/worldgen/`, `common/generation.ts`) | Phase 6 | ### Rules that keep existing worlds working Worlds saved before any of this must load afterwards with nothing changed: 1. **Ids don't change.** Saves store blocks and items by string id, including player inventories and chest contents. 2. **Terrain is identical, block for block.** A save only stores what players changed _on top of_ generated terrain. If the generator's output changes at all, every existing world silently changes with it: trees move and blocks players broke reappear. The golden terrain test (phase 6) enforces this. 3. **Old saves are upgraded when loading.** The save format can change when it has to, like version 3 did for containers, as long as older saves load into the new shape. `tests/fixtures/` keeps a save from each version. 4. **The protocol can change, with `PROTOCOL_VERSION`.** Clients on another version are asked to reload before anything else happens. ## Implementation plan **Done so far:** the mod loader, `.bmod` mod files (packing, loading them from `server_mods/`, sending players a copy without the server script), delivery to clients (hashed downloads, the confirm screen for other servers, credits); the data layer (blocks, block models with variants, items, recipes, ores); server scripts with components, events, commands, timers, storage, recipes, containers and container screens; worldgen features and ores; the base game's block behavior as a mod; world time with day and night. The code is in `common/mod_loader.ts`, `common/mod_data.ts`, `server/game/mod_runtime.ts`, `client/mods.ts` and `common/worldgen_loader.ts`. Each phase ends with every test passing and the game playable. Phases 1 to 3 are the foundation; 4 to 6 can happen in any order after them. **Phase 1: loading and common scripts.** - `scripts.common`, bundled like the client script, run on the server and on clients between each mod's data and the next mod. - `CommonContext` with `ctx.blocks`, `ctx.items`, `ctx.models` and `ctx.recipes` (`register`, `get`, `ids`). The JSON loader goes through the same functions, so there's one path for content. - The freeze: registries close after content loads, and registering later throws. Numeric ids, block items and recipe indexes are made at the freeze, not while registering. - The registry hash: both sides hash their registries after the freeze, the client sends its hash with `ready`, and a mismatch turns the player away naming the first difference. - Recipe types become ids with `register_type` and `list`, with `shaped`, `furnace`, `fuel` and `smithing` as short names for the base game's. Server scripts get `ctx.recipes.list(type)`. - Check: a test mod registers blocks, items and recipes from code, and the server and a client end up with equal registries and hashes. A common script that registers something different on each side is caught by the hash. **Phase 2: overrides.** - `override` and `extend` in common scripts, and `overrides/*.json` for the same thing as data. `recipes.remove`. - `check-mods`: changing content of a mod that isn't a dependency is an error, two mods changing the same field is a warning. - Check: a test mod adds a component to `bworld:grass`, changes `bworld:glass`'s toughness and replaces the chest recipe, and all of it works on both sides and in the registry hash. **Phase 3: engine access.** - The engine puts its modules on the global object at startup on each side (page, game server worker, chunk workers), and the build turns `bworld/engine` imports into a shim that reads them. Types come from the engine's source. - `hook` with `before`, `replace`, `after`, priorities and unhooking. - `engine_access` in the manifest: `check-mods` and the loader refuse `bworld/engine` imports without it, and refuse loading on a game version outside the mod's range. The confirm screen and the join screen say which mods use it. - Go through the engine for things mods will want to change and make them hookable: move module functions like `move_body` onto objects the engine calls through, and turn `#private` members that don't protect a rule into normal ones. - Check: test mods hook a server method, a client method and a worldgen method, several hooks on one method run in priority order, and a throwing hook is logged without breaking the game. **Phase 4: the rest of the Game API.** - Mod channels (`ctx.net` on both sides, `{ type: "mod", channel, data }`, the size and rate limits). - Keybinds (`ctx.input.bind`), forms (`message_form`, `action_form`, `modal_form`), custom screens with `Graphics`, and HUD elements. - More events: items picked up and dropped, containers clicked, players moving between chunks, entities. - Check: the example mod below works end to end, and every server event has a test. **Phase 5: gameplay out of the engine.** - `/give` and `/time` become commands in `mods/bworld`. `/tps` stays, it's about the engine. - The crafting grid's matching and the `shaped` recipe type move into `mods/bworld`, and the player's inventory screen becomes a screen `mods/bworld` registers, opened by the engine's inventory key. - The hotbar and crosshair become `mods/bworld` HUD elements. - Mining rules (how long breaking takes, tool speed, `requires_tool`, what drops) become something `mods/bworld` sets, with the engine asking it on both sides so clients still predict breaking. - The day and night lengths and sky colors become settings `mods/bworld` makes in its common script. The engine keeps counting ticks. - Where new players spawn becomes `mods/bworld`'s choice. - Check: all tests pass unchanged, and `world_v2.json` and a version 3 save still load. **Phase 6: world generation.** - First the golden fixture: `tests/fixtures/terrain.json`, a SHA-256 of the final blocks of 64 chunks for 3 seeds, including negative coordinates and chunks with trees on their borders, recorded from the current code. - Port `common/worldgen/` to `mods/bworld/scripts/worldgen.ts` as the `bworld:overworld` terrain generator. It only uses named noises, which are exactly `noise_2d` / `noise_3d` in [Terrain generators](#terrain-generators). - Move the ore table to `mods/bworld/worldgen/ores.json`, and take the content out of `common/generation.ts`, leaving the passes and noise helpers. - The server refuses to start without a terrain generator, naming the mods that could provide one. - Check: `terrain.json` matches exactly, client and server terrain still agree, and old saves load unchanged. **Done when:** - Searching `client/`, `common/` and `server/` for `"bworld:` finds only `bworld:air`. - Removing `mods/bworld` gives an engine that starts and says it needs a terrain generator, instead of crashing somewhere. - The base game uses only the Game API, no engine access. If it needs a hook for something, that thing belongs in the Game API. - All tests pass, the golden fixtures are unchanged, and a world saved before any of this plays exactly the same. ## 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, shaders) that work on any server, like Fabric's client mods? They'd need a separate `"side": "client"` kind of mod that can't add content or change the registry hash, 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. Components could get optional handlers in common scripts that clients run to predict. - **Engine access versions.** Tying engine access mods to one minor version is simple but strict. Fabric gets further with mapped names and mixin error reporting; how much of that is worth building depends on how often the engine changes under mods. - **Game API versions.** The Game API only breaking with the game's major version is a promise that needs deprecation warnings and a changelog to keep. - **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 only entities are players and dropped items, so mob mods need entity types in the Game API first. - **Hot reload.** Restarting the server and reconnecting is the version 1 answer.