42 KiB
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. Single player works the same way, with a server running inside the browser.
This borrows from Minecraft Bedrock add-ons: data lives in JSON, blocks get behavior from named custom components, and
scripts use before/after events. Bedrock servers also push resource packs to joining players, and their server scripts
can show forms that the client draws (@minecraft/server-ui). bworld goes one step further and pushes client scripts
too, since the client is already a web page.
Contents
- Overview
- Mod layout
- manifest.json
- Identifiers
- Textures
- Blocks
- Items
- Recipes
- Server scripts
- Client scripts
- GUIs
- Mod channels
- World generation
- Delivery to clients
- Single player
- Security
- Example mod
- Implementation plan
- Open questions
Overview
A mod can have up to three scripts, each running in a different place:
| Script | Runs on | Sent to clients | Owns |
|---|---|---|---|
server |
the server, in a worker | never | game logic: block components, ticking, tile data, containers, commands, events |
client |
every client, main thread | yes | presentation: custom screens, HUD, keybinds |
worldgen |
server and client chunk workers | yes | terrain features, must be deterministic |
The mod's JSON data and textures go to both sides.
The server is the authority. Clients send what the player is trying to do ("break the block at x, y, z", "click slot 3"). The server checks it, runs mod logic and sends back what happened. For responsiveness, clients show the expected result of breaking and placing immediately, and the server sends a correction if it disagrees.
server process browser
┌───────────────────────────────────────┐ ┌──────────────────────────────────┐
│ host: http, websocket, files │ │ game client (rendering, input) │
│ ┌───────────────────────────────────┐ │ ws │ + mod client scripts │
│ │ game server worker │◄├──────────┤► (screens, HUD, keybinds) │
│ │ world, inventories, tile data │ │ protocol │ │
│ │ + mod server scripts │ │ │ chunk workers │
│ └───────────────────────────────────┘ │ │ + mod worldgen scripts │
│ chunk workers + mod worldgen scripts │ └──────────────────────────────────┘
└───────────────────────────────────────┘
Moving from the current setup to this is a big change, see the Implementation plan. Right now the server only relays block changes and doesn't know about blocks, inventories or terrain.
Mod layout
mods/
copper_tools/
manifest.json
blocks/*.json
items/*.json
recipes/*.json
worldgen/ores.json
textures/*.png
scripts/
server.ts # game logic, stays on the server
client.ts # sent to players
worldgen.ts # sent to players, also runs on the server
shared/ # anything both sides import, bundled into each
Only manifest.json is required. Data folders can have subfolders, with one definition per file.
manifest.json
{
"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. |
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:
namespaceis the mod'sid. It must match^[a-z0-9_]+$, be at most 32 characters, and not bebworld, which is the base game.namemust 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
.pngintextures/becomes a texture with id<mod_id>:<file name without .png>. Subfolders are joined with_, sotextures/ores/tin.pngbecomes<mod_id>:ores_tin. - Textures are 16×16. The server's build puts every installed mod's textures into one atlas with the base game's, and clients download that atlas instead of using their own.
- A missing texture shows the magenta and black checker and logs a warning. It isn't a load error.
Blocks
blocks/copper_block.json:
{
"format_version": 1,
"block": {
"id": "copper_tools:copper_block",
"textures": "copper_tools:copper_block",
"collision": true,
"mining": { "toughness": 5, "tool": "pickaxe", "requires_tool": true },
"drops": "copper_tools:copper_block",
"item": true,
"components": {
"copper_tools:oxidizes": { "seconds": 600, "into": "copper_tools:oxidized_copper_block" }
}
}
}
| Field | Default | Maps to BlockRegistry |
Meaning |
|---|---|---|---|
id |
required | id |
The block's id. |
textures |
required | textures |
One texture id, { top, bottom, side } or { front, side }. |
transparent |
false |
transparent |
Drawn in the transparent pass, neighbors' faces stay visible. |
alpha |
1 |
alpha |
Opacity for transparent blocks. |
collision |
true |
has_collision |
Whether entities collide with it. |
mining.toughness |
unbreakable | toughness |
Seconds to break by hand. A matching tool is 2× faster. |
mining.tool |
none | tool_to_break |
Tool type that speeds it up, like pickaxe, axe or shovel. |
mining.requires_tool |
false |
requires_tool |
Only drops when broken with mining.tool. |
drops |
nothing | drop_table |
Item id given when broken. |
item |
true |
register_block_item |
Also register an item that places this block, with the same id. |
states |
none | states |
Up to 16 bits of named state, like hoed_dirt has now. |
components |
none | the on_* hooks |
Custom components with parameters, handled by server scripts. |
The client needs this data too (for meshing, mining time and collision), so it's sent to every player. It can't contain functions.
Block states work the same as today: [{ "name": "facing", "bits": 2, "default": 0 }]. They can't change textures yet,
because the mesher ignores them. variants is reserved for that.
Items
items/copper_pickaxe.json:
{
"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 |
|---|---|---|
furnace |
input item id, output { id, count }, cook_time in ticks |
Same as the table in client/blocks/furnace.ts |
fuel |
item id, burn_time in ticks |
Same as FUEL_VALUES in furnace.ts |
Two recipes with the same input are a load error. Server scripts can also register recipe types of their own through
ctx.recipes. Crafting recipes are reserved until the game has crafting.
Server scripts
scripts.server exports setup, called once when the server starts:
import type { ServerContext } from "bworld/server";
export function setup(ctx: ServerContext) {
// register components, commands, containers, event handlers
}
Server scripts run in the game server worker. They get the mod API and standard JavaScript, and no Deno, Node or DOM
APIs. That lets the same script run on a dedicated server and in the browser for single player. They
have no file or network 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;
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/yare in slot units on a grid. The client scales and centers the screen, and draws the player's inventory below it whenplayer_inventoryis true.filteris a function(item) => booleanor one of the built-in filters"smeltable"(has a furnace recipe) and"fuel". It andoutput_onlyrun on the server, so players can't put the wrong items in by editing their client.barsare progress bars filled from 0 to 1 byscreen.set_property(id, value). Properties can also be shown as text withlabels: [{ x, y, property }].- The screen handle has
set_property,close()andon_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.
worldgen/ores.json uses the same fields as the ORES table in client/generation.ts:
{
"format_version": 1,
"ores": [
{ "id": "copper_tools:rich_copper_ore", "min_y": 5, "max_y": 40, "scale": 0.05, "threshold": 0.72 }
]
}
scripts.worldgen registers features, which run on each chunk after the base terrain and trees:
import type { WorldgenContext } from "bworld/worldgen";
export function setup(gen: WorldgenContext) {
gen.register_feature("copper_tools:boulders", (chunk) => {
if (chunk.rng.next() > 0.1) return;
const x = chunk.x * 16 + Math.floor(chunk.rng.next() * 16);
const z = chunk.z * 16 + Math.floor(chunk.rng.next() * 16);
chunk.set_block(x, chunk.height_at(x, z) + 1, z, "bworld:stone");
});
}
interface FeatureChunk {
x: number; // chunk coordinates
z: number;
seed: string;
rng: { next(): number }; // seeded from the seed, chunk and feature id
noise_2d(name: string): (x: number, z: number) => number; // cached per seed and name
noise_3d(name: string): (x: number, y: number, z: number) => number;
height_at(x: number, z: number): number; // surface height, inside this chunk only
biome_at(x: number, z: number): string;
get_block(x: number, y: number, z: number): string | undefined; // inside this chunk only
set_block(x: number, y: number, z: number, id: string): void; // up to one chunk away, like trees
}
- No
Math.random,Date,performance.nowor network access. Usechunk.rngand the noise helpers. - Worldgen can't use server or client APIs, and generated blocks don't run
on_create. - Each feature's
rngis seeded with its own id, so adding or removing another mod's feature doesn't change what this one generates.
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
setupfails, the client disconnects and shows which mod failed. It never joins with some mods missing. - The
protocolinhellois the game's protocol version. A mismatch is rejected before any mod code is downloaded. - Leaving a server reloads the page, so one server's mod code never stays loaded while playing on another.
Single player
Single player runs the same game server inside the browser, like Minecraft's integrated server:
- The game server core (world, inventories, tile data, mod server scripts) is written against a small host interface for transport and storage, with no Deno or DOM APIs.
- On a dedicated server, the host is
server/main.ts: WebSocket transport, files for storage. The core runs in a Deno worker. - In single player, the host is the page:
postMessagetransport, IndexedDB for storage. The core runs in a web worker, andserver.jsbundles are loaded fromserver_mods/, which the local build serves only to this page.
The client uses the same protocol either way, so a mod written and tested in single player behaves the same on a dedicated server.
Security
Client scripts. Mod client code runs in the game page with the page's full access. How much that matters depends on where the page came from:
- Page served by the game server (the default): that server already chose every line of JavaScript on the page, so running its mods doesn't trust it any more than loading the page did.
- Page from one origin, game server on another (
?server=): mod code from that server runs with the page's origin, including its local storage. The client shows the server's mod list and asks before loading anything, and remembers the answer per server and mod hash. The server needs CORS headers onbuild/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;
}
}
Implementation plan
None of this exists yet. Each step keeps the game working:
- Game server core. Move
client/generation.tstocommon/(it already only needs constants and the rng package) and have the server generate terrain. Pull world state, chunks, tile data and player inventories into an environment-free module behind a transport and storage interface. Run it in a Deno worker fromserver/main.ts. - 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/giveto the server. ReplaceGuiChest/GuiFurnacewith server-synced containers. - Integrated server. Run the same core in a web worker for single player, with IndexedDB storage.
- 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 tobuild/mods/andserver_mods/. - Delivery. Split
welcomeintowelcome/ready/join. Clients download, verify and run mods before joining. Add the confirm screen for cross-origin servers and CORS headers on the server. - Server scripts. Components, events, commands, system, storage and
ctx.recipes. MoveFURNACE_RECIPESandFUEL_VALUESinto the recipe registry. - GUIs. Forms, then container screens (rebuild chest and furnace with them), then custom screens, the
GraphicsAPI (built on the existing renderer and debug UI widgets) and the HUD. - Mod channels and keybinds.
- Worldgen mods. Worldgen URLs and mod ores go into the chunk worker
initmessage. Workers must finish importing before generating anything.
Steps 1–3 are engine work every multiplayer feature needs, with or without mods. Mods could start with data only (step 4 plus data in step 5) before scripts exist.
Open questions
- Chunk delivery. Clients generate terrain from the seed, which needs deterministic worldgen and trusts clients to run the same generator. Sending chunks from the server instead (like Minecraft) removes both problems but costs bandwidth, roughly 5–15 KB per chunk compressed. It could be worth it once the server generates terrain anyway.
- Client-only mods. Should players be able to install their own client mods (minimaps, UI tweaks) that work on any
server? They'd need a separate
"side": "client"kind of mod that can't add content, and servers might want to forbid them. - Client-side prediction for mods. Custom block components only run on the server, so interacting with a mod block always waits one round trip. Optional client-side component handlers for prediction could fix that later.
- Overriding base game content. Replacing
bworld:blocks is forbidden for now. An"extends"field that adds components to an existing block (for example tobworld:grass) is a possible middle ground. - Screen scaling. The GUI currently works in raw canvas pixels (
SLOT_SIZEis 54).Graphicsshould probably use a UI scale the player can change, with screens laid out in scaled units. - Entities. The 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.