Files
2026-09-25 17:14:26 -03:00

1192 lines
59 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# bworld mods specification
Status: **draft, format_version 1**. Nothing here is implemented yet, see [Implementation plan](#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](#overview)
- [Creating a mod](#creating-a-mod)
- [Mod layout](#mod-layout)
- [manifest.json](#manifestjson)
- [Identifiers](#identifiers)
- [Textures](#textures)
- [Blocks](#blocks)
- [Items](#items)
- [Recipes](#recipes)
- [Server scripts](#server-scripts)
- [Client scripts](#client-scripts)
- [GUIs](#guis)
- [Mod channels](#mod-channels)
- [World generation](#world-generation)
- [Delivery to clients](#delivery-to-clients)
- [Security](#security)
- [Example mod](#example-mod)
- [The base game as a mod](#the-base-game-as-a-mod)
- [Implementation plan](#implementation-plan)
- [Open questions](#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](#implementation-plan). Right now
the server only relays block changes and doesn't know about blocks, inventories or terrain.
## Creating a mod
```sh
deno task new-mod copper_tools "Copper Tools" # copies templates/mod to mods/copper_tools
deno task check-mods # validates every mod in mods/
```
The template has one of everything: a block with a custom component, an item, a shaped recipe, textures, and 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.
`deno task build` builds every mod in `mods/` and fails if any has errors; `deno task server` then loads them. What
works so far is listed in the [Implementation plan](#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
```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](#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](#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`:
```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 }`. |
| `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 | 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 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. They can't change textures
yet, because the mesher ignores them. `variants` is reserved for that.
## Items
`items/copper_pickaxe.json`:
```json
{
"format_version": 1,
"item": {
"id": "copper_tools:copper_pickaxe",
"texture": "copper_tools:copper_pickaxe",
"tool": "pickaxe",
"max_stack": 1,
"lore": "Better than wood, worse than iron.",
"components": { "copper_tools:durability": { "max": 250 } }
}
}
```
| Field | Default | Maps to `ItemRegistry` | Meaning |
| ------------ | -------- | ------------------------ | -------------------------------------------------- |
| `id` | required | registry key | The item's id. |
| `texture` | required | `texture_id` | Texture id. |
| `tool` | none | `tool_type` | Tool type used by blocks' `mining.tool`. |
| `places` | none | `block_id` | Block placed when used on a block face. |
| `max_stack` | `64` | new | Largest stack size. |
| `lore` | none | `get_lore` | Static tooltip text. |
| `components` | none | `on_create` / `get_lore` | Custom item components, handled by server scripts. |
Item stacks can carry `data`, a JSON value set by the server (like the watering can's water level). It's synced to
clients as part of the container it's in, so client screens can show it.
## Recipes
`recipes/smelt_copper_block.json`:
```json
{
"format_version": 1,
"recipe": {
"type": "furnace",
"input": "copper_tools:copper_block",
"output": { "id": "bworld:copper_ingot", "count": 9 },
"cook_time": 400
}
}
```
| `type` | Fields | Notes |
| --------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| `shaped` | `pattern` rows (space is empty), `key` letter to item id, `result` `{ id, count }` | 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:
```json
{
"format_version": 1,
"recipe": {
"type": "shaped",
"pattern": ["PPP", "P P", "PPP"],
"key": { "P": "bworld:planks" },
"result": { "id": "bworld:chest", "count": 1 }
}
}
```
Two furnace recipes with the same `input`, 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:
```ts
import type { ServerContext } from "bworld/server";
export function setup(ctx: ServerContext) {
// register components, commands, containers, event handlers
}
```
Server scripts run in the game server worker with **no Deno permissions** (see [Security](#security)). They get the mod
API and standard JavaScript, but no file, network or subprocess access; persistent state goes through `ctx.storage`.
```ts
interface ServerContext {
mod: { id: string; version: string };
components: ComponentRegistry; // only during setup
commands: CommandRegistry; // only during setup
events: { before: ServerBeforeEvents; after: ServerAfterEvents };
system: System;
world: ServerWorld;
players: PlayerList;
containers: ContainerApi;
recipes: RecipeApi;
ui: ServerUi; // see GUIs
net: ServerNet; // see Mod channels
storage: ModStorage;
log(...args: unknown[]): void;
}
```
### Block and item components
Blocks and items get behavior from components registered here:
```ts
ctx.components.register_block("copper_tools:oxidizes", {
on_create(block) {
block.data = { age: 0 };
},
on_second(block, params, dt) {
block.data.age += dt;
if (block.data.age >= params.seconds) {
ctx.world.set_block(block.x, block.y, block.z, params.into);
}
},
});
```
| Handler | Called when | Return value |
| ------------------------------------ | ----------------------------------------- | ------------------------------------------ |
| `on_create(block, params)` | The block is placed or set. | ignored |
| `on_break(block, params, player?)` | The block is broken or replaced. | ignored |
| `on_click(block, params, player)` | A player starts hitting it. | ignored |
| `on_interact(block, params, player)` | A player right clicks it. | `true` if handled, so no block gets placed |
| `on_tick(block, params, dt)` | Every tick (20 per second) near a player. | ignored |
| `on_second(block, params, dt)` | Every second near a player. | ignored |
`block` is `{ id, x, y, z, data }`. `data` is tile data: any JSON value, `undefined` until a handler sets it, saved with
the world and **never sent to clients**. To show tile data to a player, open a screen with it (see [GUIs](#guis)).
`on_tick` and `on_second` only run for blocks that have tile data, so a component that ticks from the start sets
`block.data` in `on_create`. They run within 6 chunks of a player. `dt` is in seconds: 0.05 for `on_tick`, 1 for
`on_second`.
The server runs a fixed 20 ticks per second. When a tick takes too long, the next ones run back to back until it has
caught up, so game time keeps pace with real time. When it's more than ten ticks behind it skips the rest and logs a
warning. `/tps` shows how it's doing. Timers (`ctx.system`) count these ticks.
When a block lists several components, each handler runs in the listed order. `on_interact` counts as handled if any
component returns `true`.
Item components use
`ctx.components.register_item(id, { on_create(item, params), get_lore(item, params), on_use(item,
params, player) })`.
`on_use` runs when a player right clicks while holding the item, both at nothing and at a block. At a block, the block's
own interaction comes first, and an item with `on_use` is used instead of being placed.
### Events
**Before events** fire before something happens. Handlers can read and change the event, or set `cancel = true` to stop
it. They can't change the world themselves; use the after event or `system.run_timeout(fn, 0)` for that. **After
events** fire once it has happened.
| Event | before | after | Payload |
| ---------------- | ------ | ----- | -------------------------------------------------------- |
| `block_break` | ✓ | ✓ | `player`, `block`, `item` |
| `block_place` | ✓ | ✓ | `player`, `block`, `face`, `item` |
| `block_interact` | ✓ | ✓ | `player`, `block`, `item` |
| `chat_send` | ✓ | ✓ | `player`, `message` (before events can change `message`) |
| `player_join` | | ✓ | `player` |
| `player_leave` | | ✓ | `player` |
| `server_start` | | ✓ | none |
| `tick` | | ✓ | `dt` in seconds, 20 times per second |
These fire for **every** player's actions, because they all go through the server. When a before event cancels a break
or place, the server tells that player's client to undo what it already showed.
### World, players, system, storage
```ts
interface ServerWorld {
get_block(x: number, y: number, z: number): string | undefined; // undefined when the chunk isn't loaded
set_block(x: number, y: number, z: number, id: string): boolean; // runs on_break / on_create, synced to everyone
get_state(x: number, y: number, z: number, name: string): number | undefined;
set_state(x: number, y: number, z: number, name: string, value: number): boolean;
get_block_data<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;
// 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;
}
```
The server loads chunks around every player (its simulation distance) and generates them itself with the same generator
as the clients. Blocks in unloaded chunks can't be read or changed, and don't tick.
### Commands
```ts
ctx.commands.register("heal", {
description: "Heal yourself",
usage: "/heal [amount]",
run(args, player) {
player.send_message(`healed ${args[0] ?? "all"}`);
},
});
```
Commands run on the server when a player types `/name` in chat. Two mods using the same name, or a mod using one of the
engine's (`give` and `tps`), is a load error. `/copper_tools:heal` always works as the unambiguous form.
## Client scripts
`scripts.client` exports `setup`, called after the client has downloaded the server's mods and before the world appears:
```ts
import type { ClientContext } from "bworld/client";
export function setup(ctx: ClientContext) {
// register screens, HUD elements, keybinds, channel handlers
}
```
```ts
interface ClientContext {
mod: { id: string; version: string };
ui: ClientUi; // see GUIs
hud: HudRegistry;
input: { bind(id: string, default_key: KeyCode, on_press: () => void): void };
net: ClientNet; // see Mod channels
player: { readonly name: string; readonly position: { x: number; y: number; z: number } };
world: { get_block(x: number, y: number, z: number): string | undefined }; // read only, what the client sees
log(...args: unknown[]): void;
}
```
Client scripts are for presentation. They can't change the world or inventories; they ask the server over a
[mod channel](#mod-channels). Anything they show should be treated as a view of the server's state. Everything in a
client script is visible to players and can be changed by them, so it must not be trusted for anything that matters.
Keybinds registered with `ctx.input.bind` get an id (`copper_tools:open_smelter`) so a future controls menu can rebind
them. They only fire while no screen is open.
## GUIs
There are three ways to show a GUI. They're listed from least code to most. Pick the first one that's enough.
### 1. Forms (server only)
Simple dialogs defined entirely by server code, like Bedrock's `@minecraft/server-ui`. The client draws them with the
game's UI style. No client script is needed.
```ts
const result = await ctx.ui.action_form(player, {
title: "Teleporter",
body: "Where to?",
buttons: [
{ text: "Spawn", icon: "bworld:compass" },
{ text: "Home" },
],
});
if (!result.canceled && result.selection === 0) {
player.teleport(0, 100, 0);
}
const settings = await ctx.ui.modal_form(player, {
title: "Smelter settings",
fields: [
{ type: "toggle", label: "Auto-eject", default: true },
{ type: "slider", label: "Speed", min: 1, max: 4, step: 1, default: 1 },
{ type: "dropdown", label: "Output", options: ["Chest", "Ground"] },
{ type: "text", label: "Name", placeholder: "smelter", max_length: 32 },
],
});
// settings.values = [true, 3, 0, "smelter"], checked against the fields before your code sees them
```
| Form | Shows | Resolves with |
| -------------- | ------------------------------ | ------------------------------------------------------- |
| `message_form` | title, body, two buttons | `{ canceled, selection: 0 \| 1 }` |
| `action_form` | title, body, a list of buttons | `{ canceled, selection: number }` |
| `modal_form` | title, input fields | `{ canceled, values: (boolean \| number \| string)[] }` |
The promise resolves with `canceled: true` when the player closes the form, disconnects, or another screen replaces it.
The engine checks every response against the form's definition (types, ranges, option counts, text length) before your
code sees it.
### 2. Container screens (server only)
For inventories and machines. The server owns the container's contents and syncs them to every player viewing it. Clicks
go to the server, which applies them. The chest and furnace would be rebuilt this way instead of their hand-written
`GuiChest` / `GuiFurnace` classes.
```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, {
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.
```ts
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:
```ts
// client.ts
import type { ClientContext, Graphics, ModScreen } from "bworld/client";
export function setup(ctx: ClientContext) {
ctx.ui.register_screen("copper_tools:smelter_stats", (props) => new SmelterStats(ctx, props));
}
class SmelterStats implements ModScreen {
constructor(private ctx: ClientContext, private props: { total_smelted: number; top: [string, number][] }) {}
on_render(g: Graphics) {
g.panel(g.width / 2 - 200, g.height / 2 - 150, 400, 300);
g.text(`Smelted: ${this.props.total_smelted}`, g.width / 2 - 180, g.height / 2 - 130, { scale: 2 });
this.props.top.forEach(([id, count], i) => {
g.item(id, g.width / 2 - 180, g.height / 2 - 90 + i * 60);
g.text(`${count}`, g.width / 2 - 110, g.height / 2 - 75 + i * 60);
});
if (g.button("Reset", g.width / 2 - 60, g.height / 2 + 100, 120, 32)) {
this.ctx.net.send("copper_tools:reset_stats", {});
}
}
// the server sent new props for this screen
on_props(props: SmelterStats["props"]) {
this.props = props;
}
}
```
```ts
// server.ts
const screen = ctx.ui.open_screen(player, "copper_tools:smelter_stats", { total_smelted: 42, top: [] });
screen.update({ total_smelted: 43, top: [] }); // calls on_props on the client
screen.on_close(() => ctx.log("closed"));
```
```ts
interface ModScreen {
on_open?(): void;
on_tick?(dt: number): void;
on_render(g: Graphics): void;
on_props?(props: unknown): void;
on_close?(): void;
// return true to keep the screen open when escape is pressed
on_escape?(): boolean;
}
// immediate mode, like the debug ui, styled with assets/sprites/ui.png
interface Graphics {
readonly width: number;
readonly height: number;
readonly mouse: { x: number; y: number; down: boolean; pressed: boolean };
rect(x: number, y: number, w: number, h: number, color?: [number, number, number, number]): void;
panel(x: number, y: number, w: number, h: number): void; // nine slice background
text(text: string, x: number, y: number, options?: { scale?: number; color?: number[] }): void;
measure_text(text: string, scale?: number): number;
texture(id: string, x: number, y: number, w: number, h: number): void; // any atlas texture
item(id: string, x: number, y: number, count?: number): void;
clip(x: number, y: number, w: number, h: number, draw: () => void): void;
button(label: string, x: number, y: number, w: number, h: number): boolean; // true on the frame it's clicked
text_input(id: string, x: number, y: number, w: number): string;
slider(id: string, x: number, y: number, w: number, min: number, max: number): number;
// server synced slots, same behavior as container screens
slots(container: string, layout: { slot: number; x: number; y: number }[], x: number, y: number): void;
key_pressed(key: KeyCode): boolean;
}
```
Custom screens can also show server containers. The server passes them in when opening:
`ctx.ui.open_screen(player, id, props, { containers: { input: container } })`, and the screen draws them with
`g.slots("input", layout, x, y)`. Clicks still go to the server and follow the container rules, so a custom screen can't
bypass `filter`.
A client script can also open its own screens with `ctx.ui.open(id, props)`, for things like a settings page that
doesn't involve the server.
### HUD
```ts
ctx.hud.register("copper_tools:heat", {
on_render(g) {
g.text(`Heat: ${heat}`, 8, 8);
},
});
```
HUD elements draw every frame after the world, under open screens. They get their data from mod channels.
## Mod channels
Client and server parts of a mod talk over named channels. Messages are JSON.
```ts
// server.ts
ctx.net.on("copper_tools:reset_stats", (player, _data) => {
if (!is_admin(player)) return;
stats.clear();
});
ctx.net.send(player, "copper_tools:heat", { value: 12 });
ctx.net.broadcast("copper_tools:announcement", { text: "the smelter exploded" });
// client.ts
ctx.net.on("copper_tools:heat", (data) => {
heat = data.value;
});
ctx.net.send("copper_tools:reset_stats", {});
```
- Channel ids are in the mod's namespace. A mod can listen to other mods' channels but only send on its own.
- A message is at most 16 KB of JSON. Each player can send at most 60 mod messages per second in total; the server drops
anything over that and logs it.
- **Everything a client sends is untrusted.** Check the player's permission, check every field's type and range, and
never use client data as an item id or amount without validating it.
- Messages to a channel nobody listens to are dropped, with a warning in development builds.
- On the wire this is one protocol message, `{ type: "mod", channel, data }`, in both directions.
## World generation
Chunks are generated from the world seed by chunk workers on **both** the server and every client, and all of them must
produce identical terrain. So worldgen scripts must be **deterministic**.
Each chunk is generated in three passes:
1. **Terrain.** One terrain generator fills in the ground and water.
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 3 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`. It places no trees or other
features yet. `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 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.
```ts
export function setup(gen: WorldgenContext) {
gen.register_terrain("bworld:overworld", (chunk) => {
const height_noise = chunk.noise_2d("height");
for (let x = 0; x < 16; x++) {
for (let z = 0; z < 16; z++) {
const wx = chunk.x * 16 + x;
const wz = chunk.z * 16 + z;
const height = Math.floor((height_noise(wx * 0.01, wz * 0.01) + 1) * 15 + 50);
for (let y = 0; y <= height; y++) {
chunk.set_block(wx, y, wz, y === height ? "bworld:grass" : "bworld:stone");
}
chunk.set_height(wx, wz, height);
chunk.set_biome(wx, wz, "bworld:plains");
}
}
});
}
```
A terrain generator gets a `TerrainChunk`: a `FeatureChunk` (below) plus `set_height(x, z, height)` and
`set_biome(x, z, biome)`, which feed `height_at` / `biome_at` in later passes. Its `set_block` can write up to one chunk
away, like trees. Writes into another chunk only fill air, so the result doesn't depend on which chunk generates first.
Seeding is exact, so a generator ported from the current code produces the same terrain:
- `noise_2d(name)` is `create_noise_2d(new Alea(seed + "_" + name))` from `@paulaboks/rng`, and `noise_3d` is the same
with `create_noise_3d`. Both are cached per seed and name.
- A terrain generator's `rng` is `new Alea(seed + "_chunk_" + x + "_" + z)`.
- A feature's `rng` is `new Alea(seed + "_feature_" + feature_id + "_" + x + "_" + z)`.
### Ores
`worldgen/ores.json` uses the same fields as the `BASE_ORES` table in `common/generation.ts`:
```json
{
"format_version": 1,
"ores": [
{
"id": "copper_tools:rich_copper_ore",
"replaces": "bworld:stone",
"min_y": 5,
"max_y": 40,
"scale": 0.05,
"threshold": 0.72
}
]
}
```
Each block in the chunk that is `replaces` and between `min_y` and `max_y` becomes the first ore whose `noise_3d(id)` at
`(x, y, z) * scale` is above `threshold`.
### Features
`scripts.worldgen` registers features, which run on each chunk after terrain and ores:
```ts
import type { WorldgenContext } from "bworld/worldgen";
export function setup(gen: WorldgenContext) {
gen.register_feature("copper_tools:boulders", (chunk) => {
if (chunk.rng.next() > 0.1) return;
const x = chunk.x * 16 + Math.floor(chunk.rng.next() * 16);
const z = chunk.z * 16 + Math.floor(chunk.rng.next() * 16);
chunk.set_block(x, chunk.height_at(x, z) + 1, z, "bworld:stone");
});
}
```
```ts
interface FeatureChunk {
x: number; // chunk coordinates
z: number;
seed: string;
rng: { next(): number }; // seeded from the seed, chunk and feature id
noise_2d(name: string): (x: number, z: number) => number; // cached per seed and name
noise_3d(name: string): (x: number, y: number, z: number) => number;
height_at(x: number, z: number): number; // surface height, inside this chunk only
biome_at(x: number, z: number): string;
get_block(x: number, y: number, z: number): string | undefined; // inside this chunk only
set_block(x: number, y: number, z: number, id: string): void; // up to one chunk away, like trees
}
```
- **No `Math.random`, `Date`, `performance.now` or network access.** Use `chunk.rng` and the noise helpers.
- Worldgen can't use server or client APIs, and generated blocks don't run `on_create`.
- Each feature's `rng` is seeded with its own id, so adding or removing another mod's feature doesn't change what this
one generates.
## 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 } │
├────────────────────────────────────────►│ 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, register data │
│ in the listed order, run client │
│ setup(), start chunk workers │
│ │
│ ready │
├────────────────────────────────────────►│
│ 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.
**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`
```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`
```json
{
"format_version": 1,
"block": {
"id": "copper_tools:smelter",
"textures": { "front": "copper_tools:smelter_front", "side": "bworld:stone" },
"mining": { "toughness": 5, "tool": "pickaxe", "requires_tool": true },
"drops": "copper_tools:smelter",
"components": { "copper_tools:smelter": { "speed": 2 } }
}
}
```
`mods/copper_tools/scripts/server.ts`
```ts
import type { ServerContext } from "bworld/server";
interface SmelterData {
container: string;
progress: number;
smelted: number;
}
export function setup(ctx: ServerContext) {
ctx.components.register_block("copper_tools:smelter", {
on_create(block) {
block.data = { container: ctx.containers.create(3).id, progress: 0, smelted: 0 } satisfies SmelterData;
},
on_break(block) {
ctx.containers.delete((block.data as SmelterData).container);
},
on_interact(block, _params, player) {
const data = block.data as SmelterData;
ctx.ui.open_container(player, {
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`
```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 and biomes | `common/worldgen/` | terrain generator `bworld:overworld` in `scripts/worldgen.ts` |
| Ore table (`BASE_ORES`) | `common/generation.ts` | `worldgen/ores.json`, unchanged |
| 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 covers
loading mods (the base game and the template), their scripts and worldgen, saves, 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). _Done._
- 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. They were generated this way, checked equal to the
TypeScript definitions, and those were then deleted.
- 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
`ScreenLayout`s 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 `common/worldgen/` to `mods/bworld/scripts/worldgen.ts` as the `bworld:overworld` terrain generator. It's a
straight port: it only uses named noises, which are exactly `noise_2d` / `noise_3d` in
[Terrain generators](#terrain-generators).
- Move `BASE_ORES` to `mods/bworld/worldgen/ores.json`. They already run through the same ore pass as mods' ores, in
the same order, so the result is identical.
- 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 `server/game/game_server.ts` with the `replaceable` block field, like
`client/game_mode.ts` already does.
- `/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–6 are done and 9 mostly, enough that a mod made from the template loads and runs. 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/`. _Done._
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. _Done._
6. **Server scripts.** Components, events, commands, system, storage and `ctx.recipes`. Move `FURNACE_RECIPES` and
`FUEL_VALUES` into the recipe registry. _Done_ (`server/game/mod_runtime.ts`, with the loop in
`server/game/game_loop.ts`). `ctx.containers`, `ctx.ui` and `ctx.net` throw until steps 7 and 8, and so do the
client's `ctx.ui`, `ctx.hud`, `ctx.input` and `ctx.net`.
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. _Done for features and ores._ `register_terrain` throws until phase 3 moves the base
terrain out of the engine.
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](#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 only entities are players (`client/entity/`), so mob mods are out of scope until there are more
entity types.
- **Hot reload.** Restarting the server and reconnecting is the version 1 answer.