Files
bworld/MODS.md
T
2026-09-26 12:50:57 -03:00

80 KiB
Raw Blame History

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

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.

  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

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 builds every mod in mods/ and fails if any has errors; deno task server then loads them.

Mod layout

mods/
  copper_tools/
    manifest.json
    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:

  • 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). 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 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 <mod_id>:<file name without .png>. Subfolders are joined with _, so textures/ores/tin.png becomes <mod_id>:ores_tin.
  • Textures are 16×16. The server's build puts every installed mod's textures into one atlas with the base game's, and clients download that atlas instead of using their own.
  • A missing texture shows the magenta and black checker (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:

{
	"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.
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.
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:

{
	"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-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). 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 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:

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.

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 / 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.
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:

  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.

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:

{
	"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.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.

{ "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

  • 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

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. 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 for what it means for players and servers.

Delivery to clients

Build

deno task build builds each mod in mods/ into two places:

build/mods/<id>/<hash>/          # public, served to players
  manifest.json
  data.json                      # all blocks, items, models, recipes, overrides and ores merged
  common.js
  client.js
  worldgen.js
server_mods/<id>/<hash>/         # private, outside the static root, never served
  server.js

<hash> is a content hash of the mod's public files, so URLs never change content. The server sends them with Cache-Control: immutable, and players only download a mod again when it changes. Textures from every mod go into the server's atlas, which gets a hash-named URL the same way.

Joining

client                                     server
  │  hello { name, protocol }               │
  ├────────────────────────────────────────►│   a different protocol gets rejected { reason }
  │  welcome { protocol, seed, atlas,       │   mods[i] = { id, name, version, hash,
  │            mods[] }                     │              data, client?, worldgen?, sha256 }
  │◄────────────────────────────────────────┤   atlas = { png, json, sha256 }  (paths on the server)
  │                                         │
  │  [cross-origin: confirm screen]         │
  │  download the atlas and every mod file, │
  │  check each one's sha256, 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 file is checked against its SHA-256 before it's used, 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.
  • 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.
  • Mod files and the atlas are served with Access-Control-Allow-Origin: * and cached for a year, since their paths change whenever their content does.
  • 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 build/mods/.
  • 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.

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:

  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, build and 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.
  • 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.