Files
bworld/MODS.md
T
2026-09-24 23:28:01 -03:00

56 KiB
Raw Blame History

bworld mods specification

Status: draft, format_version 1. Nothing here is implemented yet, see Implementation plan.

Mods add blocks, items, textures, recipes, world generation, game logic and GUIs to bworld. A mod is installed on the server. Players who join download its client code, data and textures from the server automatically, so they don't install anything themselves. bworld is always played on a server; there is no single player mode.

This borrows from Minecraft Bedrock add-ons: data lives in JSON, blocks get behavior from named custom components, and scripts use before/after events. Bedrock servers also push resource packs to joining players, and their server scripts can show forms that the client draws (@minecraft/server-ui). bworld goes one step further and pushes client scripts too, since the client is already a web page.

Contents

Overview

A mod can have up to three scripts, each running in a different place:

Script Runs on Sent to clients Owns
server the server, in a worker never game logic: block components, ticking, tile data, containers, commands, events
client every client, main thread yes presentation: custom screens, HUD, keybinds
worldgen server and client chunk workers yes terrain features, must be deterministic

The mod's JSON data and textures go to both sides.

The server is the authority. Clients send what the player is trying to do ("break the block at x, y, z", "click slot 3"). The server checks it, runs mod logic and sends back what happened. For responsiveness, clients show the expected result of breaking and placing immediately, and the server sends a correction if it disagrees.

            server process                                     browser
┌───────────────────────────────────────┐          ┌──────────────────────────────────┐
│ host: http, websocket, files          │          │ game client (rendering, input)   │
│ ┌───────────────────────────────────┐ │  ws      │   + mod client scripts           │
│ │ game server worker                │◄├──────────┤►  (screens, HUD, keybinds)       │
│ │   world, inventories, tile data   │ │ protocol │                                  │
│ │   + mod server scripts            │ │          │ chunk workers                    │
│ └───────────────────────────────────┘ │          │   + mod worldgen scripts         │
│ chunk workers + mod worldgen scripts  │          └──────────────────────────────────┘
└───────────────────────────────────────┘

Moving from the current setup to this is a big change, see the Implementation plan. Right now the server only relays block changes and doesn't know about blocks, inventories or terrain.

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 all three scripts. 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/server, bworld/client and bworld/worldgen. Folders in mods/ starting with _ or . are ignored.

The game can't load mods yet; see the Implementation plan.

Mod layout

mods/
  copper_tools/
    manifest.json
    blocks/*.json
    items/*.json
    recipes/*.json
    worldgen/ores.json
    textures/*.png
    scripts/
      server.ts        # game logic, stays on the server
      client.ts        # sent to players
      worldgen.ts      # sent to players, also runs on the server
      shared/          # anything both sides import, bundled into each

Only manifest.json is required. Data folders can have subfolders, with one definition per file.

manifest.json

{
	"format_version": 1,
	"id": "copper_tools",
	"name": "Copper Tools",
	"description": "Copper blocks, a copper pickaxe and a smelter with its own screen.",
	"version": "1.0.0",
	"authors": ["paula"],
	"game_version": ">=0.1.0",
	"dependencies": [
		{ "id": "more_ores", "version": "^2.0.0" }
	],
	"scripts": {
		"server": "scripts/server.ts",
		"client": "scripts/client.ts",
		"worldgen": "scripts/worldgen.ts"
	}
}
Field Required Meaning
format_version yes Version of this spec the mod targets. Loaders reject versions they don't know.
id yes The mod's namespace, see Identifiers. Must match the folder name.
name yes Display name, shown to players when joining.
description no One or two sentences.
version yes Semver of the mod itself.
authors no List of names.
game_version no Semver range of bworld versions the mod works with.
dependencies no Other mods by id, with a semver range. Missing or mismatched dependencies fail loading.
scripts.server no Server entry. Never sent to clients.
scripts.client no Client entry, sent to every player.
scripts.worldgen no Worldgen entry, sent to every player and also run on the server.
credits no A markdown file in the mod, shown on the About page. For asset licenses and thanks.

Scripts can be .js or .ts. The build bundles each entry separately into one ES module. Code imported by both the server and client entries is copied into both bundles, so don't import secrets into shared code. Anything the client bundle imports is visible to players.

Identifiers

Everything a mod registers has an id of the form namespace:name:

  • namespace is the mod's id. It must match ^[a-z0-9_]+$ and be at most 32 characters.
  • Two namespaces are reserved. bworld belongs to the base game mod shipped in mods/bworld (see The base game as a mod). engine belongs to the engine itself, for things every game needs, like the block-breaking cracks. 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, components, screens, channels and HUD elements. It can refer to any id.
  • Registering an id that already exists is a load error.

Blocks get a numeric id when they register, and chunk data stores those numbers. Numeric ids are local to one running program, so the server and each client can number blocks differently. Saves and the network always use string ids.

Textures

  • Every .png in textures/ becomes a texture with id <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 }.
transparent false transparent Drawn in the transparent pass, neighbors' faces stay visible.
alpha 1 alpha Opacity for transparent blocks.
collision true has_collision Whether entities collide with it.
mining.toughness unbreakable toughness Seconds to break by hand. A matching tool is 2× faster.
mining.tool none tool_to_break Tool type that speeds it up, like pickaxe, axe or shovel.
mining.requires_tool false requires_tool Only drops when broken with mining.tool.
drops nothing drop_table Item id given when broken.
item true register_block_item Also register an item that places this block, with the same id.
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 the on_* hooks Custom components with parameters, handled by server scripts.

The client needs this data too (for meshing, mining time and collision), so it's sent to every player. It can't contain functions.

Block states work the same as today: [{ "name": "facing", "bits": 2, "default": 0 }]. They can't change textures yet, because the mesher ignores them. variants is reserved for that.

Items

items/copper_pickaxe.json:

{
	"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 } Fits anywhere in the 3×3 grid, like the recipes in server/game/crafting.ts
furnace input item id, output { id, count }, cook_time in ticks Same as FURNACE_RECIPES in server/game/blocks.ts
fuel item id, burn_time in ticks Same as FUEL_VALUES in server/game/blocks.ts

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, or two shaped recipes with the same pattern and key, are a load error. Server scripts can also register recipe types of their own through ctx.recipes.

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.

interface ServerContext {
	mod: { id: string; version: string };
	components: ComponentRegistry; // only during setup
	commands: CommandRegistry; // only during setup
	events: { before: ServerBeforeEvents; after: ServerAfterEvents };
	system: System;
	world: ServerWorld;
	players: PlayerList;
	containers: ContainerApi;
	recipes: RecipeApi;
	ui: ServerUi; // see GUIs
	net: ServerNet; // see Mod channels
	storage: ModStorage;
	log(...args: unknown[]): void;
}

Block and item components

Blocks and items get behavior from components registered here:

ctx.components.register_block("copper_tools:oxidizes", {
	on_create(block) {
		block.data = { age: 0 };
	},
	on_second(block, params, dt) {
		block.data.age += dt;
		if (block.data.age >= params.seconds) {
			ctx.world.set_block(block.x, block.y, block.z, params.into);
		}
	},
});
Handler Called when Return value
on_create(block, params) The block is placed or set. ignored
on_break(block, params, player?) The block is broken or replaced. ignored
on_click(block, params, player) A player left clicks it. ignored
on_interact(block, params, player) A player right clicks it. true if handled, so no block gets placed
on_tick(block, params, dt) Every tick (20 per second) while its chunk is loaded. ignored
on_second(block, params, dt) Every second while its chunk is loaded. ignored

block is { id, x, y, z, data }. data is tile data: any JSON value, undefined until a handler sets it, saved with the world and never sent to clients. To show tile data to a player, open a screen with it (see GUIs). on_tick and on_second only run for blocks that have tile data, like today.

When a block lists several components, each handler runs in the listed order. on_interact counts as handled if any component returns true.

Item components use ctx.components.register_item(id, { on_create(item, params), get_lore(item, params), on_use(item, params, player) }).

Events

Before events fire before something happens. Handlers can read and change the event, or set cancel = true to stop it. They can't change the world themselves; use the after event or system.run_timeout(fn, 0) for that. After events fire once it has happened.

Event before after Payload
block_break ✓ ✓ player, block, item
block_place ✓ ✓ player, block, face, item
block_interact ✓ ✓ player, block, item
chat_send ✓ ✓ player, message (before events can change message)
player_join ✓ player
player_leave ✓ player
server_start ✓ none
tick ✓ dt in seconds, 20 times per second

These fire for every player's actions, because they all go through the server. When a before event cancels a break or place, the server tells that player's client to undo what it already showed.

World, players, system, storage

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;
	is_loaded(x: number, z: number): boolean;
	readonly seed: string;
}

interface Player {
	readonly id: string;
	readonly name: string;
	readonly position: { x: number; y: number; z: number };
	readonly inventory: Container; // 36 slots, hotbar is 0-8
	readonly selected_slot: number;
	readonly held_item: ItemStack | undefined;
	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;
}

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 a base game name like give, is a load error. /copper_tools:heal always works as the unambiguous form.

Client scripts

scripts.client exports setup, called after the client has downloaded the server's mods and before the world appears:

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;
}

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)

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 chest and furnace would be rebuilt this way instead of their hand-written GuiChest / GuiFurnace classes.

ctx.components.register_block("copper_tools:smelter", {
	on_create(block) {
		block.data = { container: ctx.containers.create(3).id, progress: 0 };
	},
	on_interact(block, _params, player) {
		const container = ctx.containers.get(block.data.container)!;
		const screen = ctx.ui.open_container(player, {
			title: "Smelter",
			layout: [
				{ slot: 0, x: 3, y: 0, filter: "smeltable" },
				{ slot: 1, x: 3, y: 2, filter: (item) => ctx.recipes.is_fuel(item.id) },
				{ slot: 2, x: 5, y: 1, output_only: true },
			],
			container,
			player_inventory: true,
			bars: [{ id: "progress", x: 4, y: 1, texture: "bworld:arrow" }],
		});
		screen.set_property("progress", block.data.progress);
		return true;
	},
});
  • x / y are in slot units on a grid. The client scales and centers the screen, and draws the player's inventory below it when player_inventory is true.
  • filter is a function (item) => boolean or one of the built-in filters "smeltable" (has a furnace recipe) and "fuel". It and output_only run on the server, so players can't put the wrong items in by editing their client.
  • bars are progress bars filled from 0 to 1 by screen.set_property(id, value). Properties can also be shown as text with labels: [{ x, y, property }].
  • The screen handle has set_property, close() and on_close(fn). Changes to the container (from scripts, hoppers, other players) show up for everyone viewing it.
interface ContainerApi {
	create(size: number): Container; // saved with the world
	get(id: string): Container | undefined;
	delete(id: string): void;
}

interface Container {
	readonly id: string;
	readonly size: number;
	get(slot: number): ItemStack | undefined;
	set(slot: number, item: ItemStack | undefined): void;
	add(item: ItemStack): ItemStack | undefined; // returns what didn't fit
	on_change(fn: (slot: number) => void): () => void;
}

3. Custom screens (client code)

For anything forms and containers can't do: maps, skill trees, minigames, custom layouts. The client script registers a screen class, and the server opens it with some props:

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

ctx.hud.register("copper_tools:heat", {
	on_render(g) {
		g.text(`Heat: ${heat}`, 8, 8);
	},
});

HUD elements draw every frame after the world, under open screens. They get their data from mod channels.

Mod channels

Client and server parts of a mod talk over named channels. Messages are JSON.

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

Terrain generators

A world uses exactly one terrain generator, registered by a worldgen script. The base game's is bworld:overworld. 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 ORES table in common/generation.ts, plus the block they replace:

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

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, recipes and ores merged
  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 }               │
  ├────────────────────────────────────────►│
  │  welcome { seed, atlas, mods[] }        │   mods[i] = { id, name, version, hash,
  │◄────────────────────────────────────────┤              data, client?, worldgen? }  (urls)
  │                                         │
  │  [cross-origin: confirm screen]         │
  │  fetch data, atlas, scripts             │
  │  check hashes, register data,           │
  │  run client setup(), start workers      │
  │                                         │
  │  ready                                  │
  ├────────────────────────────────────────►│
  │  join { players, changes, inventory }   │   player_join fires on the server here
  │◄────────────────────────────────────────┤
  • The client registers the base game, then each mod's data in the order the server lists them.
  • If any download, hash check or setup fails, the client disconnects and shows which mod failed. It never joins with some mods missing.
  • The protocol in hello is the game's protocol version. A mismatch is rejected before any mod code is downloaded.
  • Leaving a server reloads the page, so one server's mod code never stays loaded while playing on another.

Security

Client scripts. Mod client code runs in the game page with the page's full access. How much that matters depends on where the page came from:

  • Page served by the game server (the default): that server already chose every line of JavaScript on the page, so running its mods doesn't trust it any more than loading the page did.
  • Page from one origin, game server on another (?server=): mod code from that server runs with the page's origin, including its local storage. The client shows the server's mod list and asks before loading anything, and remembers the answer per server and mod hash. The server needs CORS headers on build/mods/.
  • The hash check confirms the files are the ones the server listed. It doesn't protect against a malicious server.

Server scripts. They run in a worker with no Deno permissions (Deno worker permissions; currently needs --unstable-worker-options). They can't read files, open connections or run programs. Everything they need goes through the mod API. Installing a server mod still means trusting it with the game world and everything players send.

Players. Clients are untrusted. The server checks reach and the item being held for every break, place and interact, applies container rules on the server, validates form responses, and rate-limits mod channels. Server script code and tile data are never sent to clients.

Example mod

A smelter block with a container screen, a stats screen written in client code, and a channel between them.

mods/copper_tools/manifest.json

{
	"format_version": 1,
	"id": "copper_tools",
	"name": "Copper Tools",
	"version": "1.0.0",
	"scripts": { "server": "scripts/server.ts", "client": "scripts/client.ts" }
}

mods/copper_tools/blocks/smelter.json

{
	"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, {
				title: "Smelter",
				container: ctx.containers.get(data.container)!,
				layout: [
					{ slot: 0, x: 3, y: 0, filter: "smeltable" },
					{ slot: 1, x: 3, y: 2, filter: "fuel" },
					{ slot: 2, x: 5, y: 1, output_only: true },
				],
				player_inventory: true,
				bars: [{ id: "progress", x: 4, y: 1, texture: "bworld:arrow" }],
			}).set_property("progress", data.progress);
			return true;
		},
		on_tick(block, params) {
			const data = block.data as SmelterData;
			// ... smelt using ctx.recipes, data.progress += params.speed, data.smelted += 1 when done
		},
	});

	ctx.commands.register("smelterstats", {
		description: "Show stats for the smelter you're looking at",
		usage: "/smelterstats",
		run(_args, player) {
			ctx.ui.open_screen(player, "copper_tools:smelter_stats", { smelted: count_all_smelted() });
		},
	});

	ctx.net.on("copper_tools:reset_stats", (player) => {
		if (player.name !== ctx.storage.get("owner")) return;
		reset_all_smelted();
	});
}

mods/copper_tools/scripts/client.ts

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 base game as a mod

Everything that makes bworld bworld (its blocks, items, recipes, textures, block behavior and terrain) moves into a mod at mods/bworld, written against the same API as any other mod. The engine keeps only what every game built on it needs.

This is how the mod 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 instead of two, so mods can do anything the base game does.

What moves

Content Now In mods/bworld
18 blocks common/blocks/*.ts blocks/*.json
11 items common/items/*.ts items/*.json
62 textures assets/sprites/textures/ textures/
5 crafting recipes server/game/crafting.ts recipes/*.json, type shaped
6 furnace recipes, 2 fuels server/game/blocks.ts recipes/*.json, types furnace and fuel
Hoeing grass and dirt server/game/blocks.ts component bworld:hoeable, params { tool, into }
Chest server/game/blocks.ts component bworld:storage, params { rows }
Furnace (smelting, fuel, its screen) server/game/blocks.ts component bworld:furnace
Watering can's starting water common/items/watering_can.ts item component bworld:watering_can, params { max_water }
Terrain, biomes and trees common/generation.ts terrain generator bworld:overworld in scripts/worldgen.ts
Ore table common/generation.ts worldgen/ores.json, if it generates identically (see phase 3)
Texture credits (the Kenney packs) assets/ASSETS.md CREDITS.md, listed in the manifest's credits

Not moved:

  • Crops (common/blocks/crops.ts) are unfinished and mostly commented out. They stay where they are and get rebuilt as a component once server scripts exist, as a good first test of tile data plus ticking.
  • The watering can's lore (get_lore) isn't shown anywhere yet, because the game has no item tooltips. It moves once tooltips exist.

What stays in the engine

Rendering, physics and player controls; chunks, meshing and the generation passes (noise helpers, chunk assembly, ores, features); networking, saving and the game server; registries and the mod loader; inventories, the crafting grid, slot click rules and the generic container screen; chat and /give; bworld:air; and the engine assets in assets/: the UI sprites, font, player sprite and the engine: textures (the breaking cracks and the missing texture).

Rules that keep existing worlds working

Worlds saved before the move 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. The JSON uses exactly the ids the TypeScript files register now.
  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 0) enforces this.
  3. Tile data keeps its shape. Chests store their items in containers.main, and furnaces keep containers.main plus progress, progress_max, fuel and fuel_max in data. The components read exactly those, so tiles saved by the current code load into them.
  4. The save format and protocol don't change. Clients receive mods/bworld like any other mod.

Phases

Each phase ends with every test passing and the game playable.

Phase 0: safety net. Do this first, before any other mod work. Started: deno task test runs tests/, which so far checks mods/bworld against the game and the mod tools.

  • Move the test scripts used while building steps 1–3 into the repo as deno test files under tests/: server logic (breaking, placing, crafting, chest, furnace, saves), client and server terrain agreement, click prediction, and the end-to-end WebSocket test.
  • Record golden fixtures from the current code: tests/fixtures/registry.json, every block and item with all its fields (functions left out), and 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.
  • Save a world from the current code with chests, a running furnace and some player inventories as tests/fixtures/world_v2.json, with a test that loads it and checks everything is where it was.

Phase 1: data (after steps 4 and 5).

  • Create mods/bworld with its manifest and credits, and move the 62 textures there. Rename the breaking cracks to engine:break_0–8 and the fallback to engine:missing.
  • Generate the block, item and recipe JSON with a script that reads the current registries, instead of writing it by hand. Hand-copying 18 blocks' fields is how typos get in. Done: deno task export-bworld writes them, and tests/bworld_mod_test.ts fails if they drift from the game.
  • The loader registers the recipes and server/game/crafting.ts matches against them. Behaviors stay in server/game/blocks.ts, still keyed by block id, for now.
  • Delete common/blocks/ and common/items/.
  • Check: the registry built from JSON equals registry.json, and all tests pass.

Phase 2: behavior (after step 6, and container screens from step 7).

  • mods/bworld/scripts/server.ts registers bworld:hoeable, bworld:storage, bworld:furnace and bworld:watering_can. The chest and furnace open their screens with ctx.ui.open_container, whose layouts are the ScreenLayouts the server sends now.
  • The block JSON lists the components. Delete server/game/blocks.ts.
  • Check: the server logic tests (hoeing, chest, furnace smelting, breaking a chest gives its contents back) and world_v2.json still pass.

Phase 3: world generation (after step 9).

  • Port generate_chunk to mods/bworld/scripts/worldgen.ts as the bworld:overworld terrain generator. It's a straight port: the seeding rules in Terrain generators were chosen so the same noise names give the same values.
  • Keep trees inside the terrain generator rather than making them a feature. Right now a tree's leaves can be overwritten by later columns of the same chunk; as a feature running after all terrain, they would win instead, and the terrain would change.
  • Try moving the ores to ores.json with "replaces": "bworld:stone". The current code only places ores in stone, so this should be identical, but only the golden test can say so. If it isn't, the ores stay inside the terrain generator.
  • Remove the content from common/generation.ts, leaving the passes and noise helpers.
  • Check: terrain.json matches exactly, client and server terrain still agree, and world_v2.json loads unchanged.

Phase 4: engine cleanup.

  • Replace the hardcoded water checks in client/systems/player_controls.ts and server/game/game_server.ts with the replaceable block field.
  • /give stops assuming bworld:. It looks names up across all namespaces and asks for the full id when two mods have the same name.
  • Rename engine asset keys like bworld:ui and bworld:m6x11 in client/main.ts to engine:, so bworld: only means content.
  • The About page shows the engine's assets/ASSETS.md plus every loaded mod's credits.
  • The server refuses to start without a terrain generator, naming the mods that could provide one.

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.
  • All tests pass, the golden fixtures are unchanged, and a world saved before the move plays exactly the same after it.

Implementation plan

Steps 1–3 are done; the mod steps (4 onwards) aren't started. Each step keeps the game working:

  1. Game server core. Move client/generation.ts to common/ (it already only needs constants and the rng package) and have the server generate terrain. Move world state, chunks, tile data and player inventories into a game server module that runs in a Deno worker, with server/main.ts handling HTTP, WebSockets and files.
  2. Server authority. Change the protocol from "here's the block I changed" to intents (break_block, place_block, interact, select_slot, container_click). The client keeps showing breaks and places immediately and accepts corrections. Move inventories, drops and /give to the server. Replace GuiChest / GuiFurnace with server-synced containers.
  3. Server only. Remove the offline fallback in client/main.ts, which currently starts a local game when it can't reach a server, and show a connection error instead.
  4. Mod loader and build. Discover mods, check manifests, sort by dependencies, turn JSON into registry entries, build the combined atlas (textures are currently all named bworld:<file>), bundle scripts per side, and write hashed output to build/mods/ and server_mods/.
  5. Delivery. Split welcome into welcome / ready / join. Clients download, verify and run mods before joining. Add the confirm screen for cross-origin servers and CORS headers on the server.
  6. Server scripts. Components, events, commands, system, storage and ctx.recipes. Move FURNACE_RECIPES and FUEL_VALUES into the recipe registry.
  7. GUIs. Forms, then container screens (rebuild chest and furnace with them), then custom screens, the Graphics API (built on the existing renderer and debug UI widgets) and the HUD.
  8. Mod channels and keybinds.
  9. Worldgen mods. Worldgen URLs and mod ores go into the chunk worker init message. Workers must finish importing before generating anything.

Steps 1–3 are engine work every multiplayer feature needs, with or without mods. Mods could start with data only (step 4 plus data in step 5) before scripts exist.

Moving the base game into mods/bworld happens alongside steps 4–9. See The base game as a mod for which phase follows which step.

Open questions

  • Chunk delivery. Clients generate terrain from the seed, which needs deterministic worldgen and trusts clients to run the same generator. Sending chunks from the server instead (like Minecraft) removes both problems but costs bandwidth, roughly 5–15 KB per chunk compressed. It could be worth it once the server generates terrain anyway.
  • Client-only mods. Should players be able to install their own client mods (minimaps, UI tweaks) that work on any server? They'd need a separate "side": "client" kind of mod that can't add content, and servers might want to forbid them.
  • Client-side prediction for mods. Custom block components only run on the server, so interacting with a mod block always waits one round trip. Optional client-side component handlers for prediction could fix that later.
  • Overriding base game content. Replacing bworld: blocks is forbidden for now. An "extends" field that adds components to an existing block (for example to bworld:grass) is a possible middle ground.
  • Screen scaling. The GUI currently works in raw canvas pixels (SLOT_SIZE is 54). Graphics should probably use a UI scale the player can change, with screens laid out in scaled units.
  • Entities. The game's only entity is the player, so mob mods are out of scope until the ECS has more entity types.
  • Hot reload. Restarting the server and reconnecting is the version 1 answer.