82 KiB
bworld mods specification
Status: draft, format_version 1, partly built. The 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
- Loading
- Creating a mod
- Mod layout
- manifest.json
- Identifiers
- Data: Textures, Blocks, Block models, Items, Recipes, Overrides
- Game API: Common scripts, Server scripts, Client scripts, GUIs, Mod channels, World generation
- Engine access
- Delivery to clients
- Security
- Example mod
- The engine and the base game
- Implementation plan
- 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 | 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.
- Content, one mod at a time: the mod's JSON data is registered, then its
commonscript'ssetupruns. So a common script sees its own data and everything from the mods before it, and can change it. - Freeze. The registries close: blocks get their numeric ids, block items are created and recipes are indexed. Registering or changing content after this throws.
- Scripts.
serverscripts'setupruns on the server,clientscripts' on each client, andworldgenscripts in every chunk worker. These can read every registry but not change them. - The server starts ticking, or the client sends
readyand 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
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 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
{
"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. 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. 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:
namespaceis the mod'sid. It must match^[a-z0-9_]+$and be at most 32 characters.- Two namespaces are reserved.
bworldbelongs to the base game mod shipped inmods/bworld(see The engine and the base game).enginebelongs to the engine itself, for things every game needs, like the block-breaking cracks and the built-in block models. The one exception isbworld:air: it's the engine's empty block, but it keeps that id because every save already contains it. namemust 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 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
.pngintextures/becomes a texture with id<mod_id>:<file name without .png>. Subfolders are joined with_, sotextures/ores/tin.pngbecomes<mod_id>: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
enginenamespace:engine:missingand the breaking cracksengine:break_0toengine:break_8. They come fromassets/, not from a mod.
Blocks
blocks/copper_block.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. |
name |
from the id | name |
What players see, like "Copper Block". Its item gets it too. Without it the name is made from the id: copper_tools:copper_block is "Copper Block". |
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. |
variants |
none | variants |
A different model, textures or y rotation for some states, see 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):
{
"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:
{
"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:
{
"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. |
name |
from the id | name |
What players see, like "Copper Pickaxe". Made from the id when not set. |
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 | lore |
Tooltip text under the name. \n starts a new line. |
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:
{
"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:
{
"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:
{
"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.
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 can do the same with override and extend. Planned, phase 2.
overrides/grass_weeds.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:
{
"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-modsmakes this an error. - Changes apply in load order, so when two mods change the same field the later one wins.
check-modswarns 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). Planned, phase 1.
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 } } });
}
}
interface CommonContext {
mod: { id: string; version: string };
blocks: ContentRegistry<BlockJson>;
items: ContentRegistry<ItemJson>;
models: ContentRegistry<ModelJson>;
recipes: RecipeRegistry;
log(...args: unknown[]): void;
}
// T is the same shape as the JSON file's content, checked the same way
interface ContentRegistry<T> {
register(definition: T): void;
get(id: string): Readonly<T> | undefined; // what's registered so far, by any mod
ids(): string[];
override(id: string, fields: Partial<T>): void; // like "set" in an override file
extend(id: string, change: { components?: Record<string, unknown>; 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
ctxshape on both sides on purpose, so they can't accidentally register different things. - Recipe types are ids like everything else.
shaped,furnace,fuelandsmithingare short forbworld:shapedand 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 withctx.recipes.list("copper_tools:alloy").
Server scripts
scripts.server exports setup, called once when the server starts:
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). 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), so
server scripts read registries but don't change them.
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:
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).
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. get_lore is extra tooltip text
for one stack, like the watering can's "Water: 12/32": the server works it out whenever it sends the item to a player
and shows it under the item's own lore. It isn't saved.
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
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<T>(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<T>(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 <ticks|day|noon|sunset|night|midnight> and /time add <ticks>.
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
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:
import type { ClientContext } from "bworld/client";
export function setup(ctx: ClientContext) {
// register screens, HUD elements, keybinds, channel handlers
}
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. 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.
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.
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/yare in slot units in the screen's own area and can be fractional.rowssets its height, by default it fits the slots and bars. filteris a function(item) => booleanor one of the built-in filters"smeltable"(has a furnace recipe) and"fuel". It andoutput_onlyrun on the server, so players can't put the wrong items in by editing their client.output_onlyslots can only be taken from, all at once, like the furnace result. Theiron_take(item)runs when a player takes from one, before they get it: returnfalseto stop them, or use up the inputs there so they're gone in the same step (the smithing table does this).barsare progress bars filled withvalue / max, both names of properties set withscreen.set_property(id, n).directionis"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()andon_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.
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:
// 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;
}
}
// 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"));
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.
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.
// 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:
- Terrain. One terrain generator fills in the ground, water and trees.
- Ores. Every mod's
worldgen/ores.json, in load order. - 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.
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)iscreate_noise_2d(new Alea(seed + "_" + name))from@paulaboks/rng, andnoise_3dis the same withcreate_noise_3d. Both are cached per seed and name.- A terrain generator's
rngisnew Alea(seed + "_chunk_" + x + "_" + z). - A feature's
rngisnew Alea(seed + "_feature_" + feature_id + "_" + x + "_" + z).
Ores
worldgen/ores.json uses the same fields as the BASE_ORES table in common/generation.ts:
{
"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:
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");
});
}
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.nowor network access. Usechunk.rngand the noise helpers. - Worldgen can't use server or client APIs, and generated blocks don't run
on_create. - Each feature's
rngis 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.
{ "engine_access": true, "game_version": "~0.4.0" }
// 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);
}
},
});
}
// 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;
},
});
}
// 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
engineholds 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 runninggame) in server scripts, andengine.client(Client,ClientLevel, the renderer, GUI classes, the runningclient) in client scripts. Worldgen scripts getengine.commononly.- They're the live classes and objects the game uses, not copies: the engine puts them on the global object at startup,
and
bworld/enginein 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-modstypechecks a mod against exactly the engine it will run on, and an engine change that breaks a mod shows up there first.
Hooks
function hook<T, K extends keyof T>(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<T[K]>): { result: ReturnType<T[K]> } | void;
// wraps the method, calling original (or not)
replace?(self: T, args: Parameters<T[K]>, original: T[K]): ReturnType<T[K]>;
// runs last, and returns the result the caller gets
after?(self: T, args: Parameters<T[K]>, result: ReturnType<T[K]>): ReturnType<T[K]>;
// 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.
beforehooks run in priority order,replacehooks nest (the first one'soriginalis the next one's wrapper, the last one's is the engine's method), andafterhooks 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
#privatemembers can't be hooked or read. The engine keeps things that mods should be able to change as normal members, and#privatefor 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_accessmust setgame_versionto 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, importingbworld/engineis acheck-modserror and a load error. - See 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.
deno task pack-mod mods/copper_tools # writes copper_tools.bmod
deno task build # packs every mod in mods/ into server_mods/<id>.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/<name>.png # the texture copper_tools:<name>
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 thedeno.jsonof 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 buildleaves other.bmodfiles inserver_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/<sha256>.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
.bmodis checked against its SHA-256 before it's opened, and scripts are imported from the checked bytes (asblob:URLs), so what runs is exactly what was checked. The client code is inclient/handshake.tsandclient/mods.ts. - The client builds the texture atlas itself, from the engine's textures and the ones in the
.bmodfiles (client/atlas.ts). The server never needs to decode an image. - If any download, hash check or
setupfails, the client disconnects and shows which mod failed. It never joins with some mods missing. - The
protocolinhellois the game's protocol version (PROTOCOL_VERSIONincommon/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 besidesreadyis ignored. A client that doesn't sendreadywithin 60 seconds is rejected. .bmodfiles are served withAccess-Control-Allow-Origin: *and cached for a year, since their paths change whenever their content does. The engine's assets are served withAccess-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.bmoddownloads 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
{
"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
{
"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
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
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:
- Ids don't change. Saves store blocks and items by string id, including player inventories and chest contents.
- 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.
- 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. - 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.CommonContextwithctx.blocks,ctx.items,ctx.modelsandctx.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_typeandlist, withshaped,furnace,fuelandsmithingas short names for the base game's. Server scripts getctx.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.
overrideandextendin common scripts, andoverrides/*.jsonfor 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, changesbworld: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/engineimports into a shim that reads them. Types come from the engine's source. hookwithbefore,replace,after, priorities and unhooking.engine_accessin the manifest:check-modsand the loader refusebworld/engineimports 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_bodyonto objects the engine calls through, and turn#privatemembers 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.neton both sides,{ type: "mod", channel, data }, the size and rate limits). - Keybinds (
ctx.input.bind), forms (message_form,action_form,modal_form), custom screens withGraphics, 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.
/giveand/timebecome commands inmods/bworld./tpsstays, it's about the engine.- The crafting grid's matching and the
shapedrecipe type move intomods/bworld, and the player's inventory screen becomes a screenmods/bworldregisters, opened by the engine's inventory key. - The hotbar and crosshair become
mods/bworldHUD elements. - Mining rules (how long breaking takes, tool speed,
requires_tool, what drops) become somethingmods/bworldsets, with the engine asking it on both sides so clients still predict breaking. - The day and night lengths and sky colors become settings
mods/bworldmakes 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.jsonand 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/tomods/bworld/scripts/worldgen.tsas thebworld:overworldterrain generator. It only uses named noises, which are exactlynoise_2d/noise_3din Terrain generators. - Move the ore table to
mods/bworld/worldgen/ores.json, and take the content out ofcommon/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.jsonmatches exactly, client and server terrain still agree, and old saves load unchanged.
Done when:
- Searching
client/,common/andserver/for"bworld:finds onlybworld:air. - Removing
mods/bworldgives 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_SIZEis 54).Graphicsshould 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.