1611 lines
82 KiB
Markdown
1611 lines
82 KiB
Markdown
# bworld mods specification
|
||
|
||
Status: **draft, format_version 1**, partly built. The [Implementation plan](#implementation-plan) lists what works
|
||
today and what's planned, and sections about planned features say so.
|
||
|
||
Mods add blocks, items, textures, recipes, world generation, game logic and GUIs to bworld, and can change how the game
|
||
itself works. A mod is installed **on the server**. Players who join download its client code, data and textures from
|
||
the server automatically, so they don't install anything themselves. bworld is always played on a server; there is no
|
||
single player mode.
|
||
|
||
bworld's modding takes from three places:
|
||
|
||
- **Minetest: the game is mods.** The engine is small, and everything that makes bworld _bworld_ (its blocks, items,
|
||
crafting, inventory screen, HUD, commands and terrain) lives in `mods/bworld`, written against the same API as any
|
||
other mod. Mods can change each other's content, including the base game's.
|
||
- **Minecraft Bedrock add-ons: easy to start.** Most content is JSON, blocks and items get behavior from named
|
||
components, scripts use before/after events and server-driven forms, and players get everything from the server.
|
||
- **Minecraft Java with Fabric: no ceiling.** A mod that needs to can reach into the engine itself and hook any of its
|
||
methods, on the server and on the client, like Fabric's mixins.
|
||
|
||
## Contents
|
||
|
||
- [Overview](#overview)
|
||
- [Loading](#loading)
|
||
- [Creating a mod](#creating-a-mod)
|
||
- [Mod layout](#mod-layout)
|
||
- [manifest.json](#manifestjson)
|
||
- [Identifiers](#identifiers)
|
||
- Data: [Textures](#textures), [Blocks](#blocks), [Block models](#block-models), [Items](#items),
|
||
[Recipes](#recipes), [Overrides](#overrides)
|
||
- Game API: [Common scripts](#common-scripts), [Server scripts](#server-scripts), [Client scripts](#client-scripts),
|
||
[GUIs](#guis), [Mod channels](#mod-channels), [World generation](#world-generation)
|
||
- [Engine access](#engine-access)
|
||
- [Delivery to clients](#delivery-to-clients)
|
||
- [Security](#security)
|
||
- [Example mod](#example-mod)
|
||
- [The engine and the base game](#the-engine-and-the-base-game)
|
||
- [Implementation plan](#implementation-plan)
|
||
- [Open questions](#open-questions)
|
||
|
||
## Overview
|
||
|
||
A mod is built from up to three layers. Most mods only need the first one or two.
|
||
|
||
| Layer | Like | What a mod writes | Stability |
|
||
| --------------------------------- | --------------------------------- | --------------------------------------------------------------------------------- | -------------------------------------------------- |
|
||
| **Data** | Bedrock behavior packs | JSON in `blocks/`, `items/`, `models/`, `recipes/`, `overrides/`, `worldgen/`, and textures | Versioned by `format_version` |
|
||
| **Game API** | Minetest's Lua API, Bedrock's Script API | TypeScript against `bworld/common`, `bworld/server`, `bworld/client` and `bworld/worldgen` | Stable, only breaks with the game's major version |
|
||
| **[Engine access](#engine-access)** | Fabric's mixins | `import { engine, hook } from "bworld/engine"`, opted into in the manifest | None, tied to one game version |
|
||
|
||
Data is shorthand for the Game API: loading `blocks/copper_block.json` does exactly what `ctx.blocks.register(...)` with
|
||
the same object does in a common script. So anything a JSON file can do, code can do too, for content that's easier to
|
||
generate (every color of wool) or that depends on other mods.
|
||
|
||
A mod can have up to four scripts, each running in a different place:
|
||
|
||
| Script | Runs on | Sent to clients | Owns |
|
||
| ---------- | ---------------------------------------------- | --------------- | ------------------------------------------------------------------------------------ |
|
||
| `common` | the server and every client, before the others | yes | content: registering blocks, items, models and recipes, and changing other mods' content |
|
||
| `server` | the server, in a worker | **never** | game logic: block components, ticking, tile data, containers, commands, events |
|
||
| `client` | every client, main thread | yes | presentation: custom screens, HUD, keybinds |
|
||
| `worldgen` | server and client chunk workers | yes | terrain and features, must be deterministic |
|
||
|
||
**The server is the authority.** Clients send what the player is trying to do ("break the block at x, y, z", "click slot
|
||
3"). The server checks it, runs mod logic and sends back what happened. For responsiveness, clients show the expected
|
||
result of breaking and placing immediately, and the server sends a correction if it disagrees.
|
||
|
||
```
|
||
server process browser
|
||
┌───────────────────────────────────────┐ ┌──────────────────────────────────┐
|
||
│ host: http, websocket, files │ │ game client (rendering, input) │
|
||
│ ┌───────────────────────────────────┐ │ ws │ + mod common and client │
|
||
│ │ game server worker │◄├──────────┤► scripts │
|
||
│ │ world, inventories, tile data │ │ protocol │ │
|
||
│ │ + mod common and server scripts │ │ │ chunk workers │
|
||
│ └───────────────────────────────────┘ │ │ + mod worldgen scripts │
|
||
│ chunk workers + mod worldgen scripts │ └──────────────────────────────────┘
|
||
└───────────────────────────────────────┘
|
||
```
|
||
|
||
## Loading
|
||
|
||
The server and every client load mods the same way, in the same order: dependencies first, otherwise alphabetical. The
|
||
server sends its order to clients, and they follow it.
|
||
|
||
1. **Content**, one mod at a time: the mod's JSON data is registered, then its `common` script's `setup` runs. So a
|
||
common script sees its own data and everything from the mods before it, and can change it.
|
||
2. **Freeze.** The registries close: blocks get their numeric ids, block items are created and recipes are indexed.
|
||
Registering or changing content after this throws.
|
||
3. **Scripts.** `server` scripts' `setup` runs on the server, `client` scripts' on each client, and `worldgen` scripts in
|
||
every chunk worker. These can read every registry but not change them.
|
||
4. The server starts ticking, or the client sends `ready` and joins.
|
||
|
||
**Content has to come out the same on every side.** Common scripts run on the server and on every client, and all of
|
||
them must end up with exactly the same blocks, items and recipes: no `Math.random`, dates, or anything that differs
|
||
between the server and a client. After the freeze each side hashes its registries (every id and its definition), and
|
||
the client sends the hash with `ready`. When it doesn't match the server's, the player is turned away with a message
|
||
naming the first thing that differs, instead of playing with mismatched blocks. _Planned, phase 1._
|
||
|
||
## Creating a mod
|
||
|
||
```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 a script
|
||
for each side. `check-mods` checks manifests and data files against this spec, checks that every id, item and texture a
|
||
mod refers to exists (and that it depends on the mods those come from), and typechecks scripts against the API types in
|
||
`common/mod_api/`, which scripts import as `bworld/common`, `bworld/server`, `bworld/client`, `bworld/worldgen` and,
|
||
with engine access, `bworld/engine`. Folders in `mods/` starting with `_` or `.` are ignored.
|
||
|
||
`deno task build` packs every mod in `mods/` into a `.bmod` in `server_mods/` and fails if any has errors;
|
||
`deno task server` then loads every `.bmod` there. `deno task pack-mod mods/copper_tools` packs one mod into a file to
|
||
share, see [Mod files](#mod-files).
|
||
|
||
## Mod layout
|
||
|
||
```
|
||
mods/
|
||
copper_tools/
|
||
manifest.json
|
||
deno.json # optional: the mod is its own deno project, see Mod files
|
||
blocks/*.json
|
||
models/*.json
|
||
items/*.json
|
||
recipes/*.json
|
||
overrides/*.json # changes to other mods' content
|
||
worldgen/ores.json
|
||
textures/*.png
|
||
scripts/
|
||
common.ts # content, runs on both sides
|
||
server.ts # game logic, stays on the server
|
||
client.ts # sent to players
|
||
worldgen.ts # sent to players, also runs on the server
|
||
shared/ # anything several scripts import, bundled into each
|
||
```
|
||
|
||
Only `manifest.json` is required. Data folders can have subfolders, with one definition per file.
|
||
|
||
## manifest.json
|
||
|
||
```json
|
||
{
|
||
"format_version": 1,
|
||
"id": "copper_tools",
|
||
"name": "Copper Tools",
|
||
"description": "Copper blocks, a copper pickaxe and a smelter with its own screen.",
|
||
"version": "1.0.0",
|
||
"authors": ["paula"],
|
||
"game_version": ">=0.1.0",
|
||
"dependencies": [
|
||
{ "id": "bworld", "version": "*" },
|
||
{ "id": "more_ores", "version": "^2.0.0" }
|
||
],
|
||
"scripts": {
|
||
"common": "scripts/common.ts",
|
||
"server": "scripts/server.ts",
|
||
"client": "scripts/client.ts",
|
||
"worldgen": "scripts/worldgen.ts"
|
||
}
|
||
}
|
||
```
|
||
|
||
| Field | Required | Meaning |
|
||
| ------------------ | -------- | ----------------------------------------------------------------------------------------- |
|
||
| `format_version` | yes | Version of this spec the mod targets. Loaders reject versions they don't know. |
|
||
| `id` | yes | The mod's namespace, see [Identifiers](#identifiers). Must match the folder name. |
|
||
| `name` | yes | Display name, shown to players when joining. |
|
||
| `description` | no | One or two sentences. |
|
||
| `version` | yes | Semver of the mod itself. |
|
||
| `authors` | no | List of names. |
|
||
| `game_version` | no | Semver range of bworld versions the mod works with. Mods with `engine_access` need one. |
|
||
| `dependencies` | no | Other mods by `id`, with a semver range. Missing or mismatched dependencies fail loading. A mod can only change content of mods it depends on. |
|
||
| `scripts.common` | no | Content entry, run on the server and sent to every player. _Planned, phase 1._ |
|
||
| `scripts.server` | no | Server entry. Never sent to clients. |
|
||
| `scripts.client` | no | Client entry, sent to every player. |
|
||
| `scripts.worldgen` | no | Worldgen entry, sent to every player and also run on the server. |
|
||
| `engine_access` | no | `true` lets the mod's scripts import `bworld/engine`, see [Engine access](#engine-access). _Planned, phase 3._ |
|
||
| `credits` | no | A markdown file in the mod, shown on the Credits screen. For asset licenses and thanks. |
|
||
|
||
Scripts can be `.js` or `.ts`. The build bundles each entry separately into one ES module. Code imported by several
|
||
entries is copied into each bundle, so **don't import secrets into shared code**. Anything the common, client or worldgen
|
||
bundles import is visible to players.
|
||
|
||
## Identifiers
|
||
|
||
Everything a mod registers has an id of the form `namespace:name`:
|
||
|
||
- `namespace` is the mod's `id`. It must match `^[a-z0-9_]+$` and be at most 32 characters.
|
||
- Two namespaces are reserved. `bworld` belongs to the base game mod shipped in `mods/bworld` (see
|
||
[The engine and the base game](#the-engine-and-the-base-game)). `engine` belongs to the engine itself, for things
|
||
every game needs, like the block-breaking cracks and the built-in block models. The one exception is `bworld:air`:
|
||
it's the engine's empty block, but it keeps that id because every save already contains it.
|
||
- `name` must match `^[a-z0-9_]+$`.
|
||
- A mod only registers ids in its own namespace: blocks, items, models, components, recipe types, screens, channels and
|
||
HUD elements. It can _refer to_ any id, and [change](#overrides) other mods' content.
|
||
- Registering an id that already exists is a load error. Changing an existing one is what overrides are for.
|
||
|
||
Blocks get a numeric id when they register, and chunk data stores those numbers. Numeric ids are **local to one running
|
||
program**, so the server and each client can number blocks differently. Saves and the network always use string ids.
|
||
|
||
## Textures
|
||
|
||
- Every `.png` in `textures/` becomes a texture with id `<mod_id>:<file name without .png>`. Subfolders are joined with
|
||
`_`, so `textures/ores/tin.png` becomes `<mod_id>:ores_tin`.
|
||
- Textures are 16×16. They go in the mod's `.bmod`, and clients put every mod's textures into one atlas when they join.
|
||
- A missing texture shows the magenta and black checker (`engine:missing`) and logs a warning. It isn't a load error.
|
||
- The engine's own textures use the `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 }`. With another `model`, the model's texture variables, like `{ "crop": "..." }`. |
|
||
| `model` | `engine:cube` | `model` | The [block model](#block-models). |
|
||
| `variants` | none | `variants` | A different `model`, `textures` or `y` rotation for some states, see [block models](#block-models). |
|
||
| `render_layer` | `solid` | `render_layer` | `solid`, `cutout` (texels fully opaque or fully clear, like leaves) or `translucent` (blended, like water and glass). Non-solid blocks don't hide their neighbors' faces. |
|
||
| `cull_same` | see meaning | `cull_same` | Hide faces between two of this block. Defaults to `true` for translucent blocks, `false` otherwise. |
|
||
| `light_emission` | `0` | `light_emission` | Light level 0-15 it gives off, like a torch (14). |
|
||
| `light_opacity` | see meaning | `light_opacity` | How much light passing through it loses, 0-15. Defaults to `15` (blocks all light) for solid blocks and `0` otherwise. Leaves and water use `1`. |
|
||
| `alpha` | `1` | `alpha` | Opacity for translucent blocks. |
|
||
| `collision` | `true` | `has_collision` | Whether entities collide with it. |
|
||
| `mining.toughness` | unbreakable | `toughness` | Seconds to break by hand. A matching tool is 2× faster. |
|
||
| `mining.tool` | none | `tool_to_break` | Tool type that speeds it up, like `pickaxe`, `axe` or `shovel`. |
|
||
| `mining.requires_tool` | `false` | `requires_tool` | Only drops when broken with `mining.tool`. |
|
||
| `drops` | nothing | `drop_table` | Item id dropped on the ground when broken, for players to pick up. |
|
||
| `item` | `true` | `register_block_item` | Also register an item that places this block, with the same id. |
|
||
| `interactive` | `false` | `interactive` | Right clicking it does something (opens a screen), so clients don't guess it places a block. |
|
||
| `replaceable` | `false` | new | Placing a block into it replaces it, like water. |
|
||
| `states` | none | `states` | Up to 16 bits of named state, like `hoed_dirt` has now. |
|
||
| `components` | none | `components` | Custom components with parameters, handled by server scripts. |
|
||
|
||
The client needs this data too (for meshing, mining time and collision), so it's sent to every player. It can't contain
|
||
functions.
|
||
|
||
Block states are declared like `[{ "name": "facing", "bits": 2, "default": 0 }]`, at most 16 bits in total. A block
|
||
starts with its defaults when it's placed or generated, and server scripts read and change them with
|
||
`ctx.world.get_state` / `set_state`. States are saved with the world and synced to players. `variants` changes how the
|
||
block looks with them.
|
||
|
||
## Block models
|
||
|
||
A block's shape comes from its model, `engine:cube` unless it sets `model`. The engine has three:
|
||
|
||
| Model | Texture variables | Shape |
|
||
| -------------- | -------------------------------------------------- | --------------------------------------------- |
|
||
| `engine:cube` | one texture, `{ top, bottom, side }` or `{ front, side }` | A full block. |
|
||
| `engine:cross` | `cross` | Two crossed planes, like flowers and saplings. |
|
||
| `engine:crop` | `crop` | Four planes in a # shape, like wheat. |
|
||
|
||
Blocks that aren't full cubes should use the `cutout` (or `translucent`) render layer, because `solid` blocks hide their
|
||
neighbors' faces. This is what minecraft's `"render_type": "minecraft:cutout"` does. They usually want
|
||
`"collision": false` too, and show as a flat sprite of their first texture in inventories.
|
||
|
||
`blocks/wheat.json` picks a texture per growth stage with `variants`. The keys are conditions on the block's states,
|
||
`"age=3"` or `"age=3,facing=1"` (all have to match), and the first variant that matches replaces the block's `model`,
|
||
`textures` or turns it by `y` (0, 90, 180 or 270 degrees, clockwise seen from above):
|
||
|
||
```json
|
||
{
|
||
"format_version": 1,
|
||
"block": {
|
||
"id": "bworld:wheat",
|
||
"model": "engine:crop",
|
||
"textures": { "crop": "bworld:wheat_stage_0" },
|
||
"variants": {
|
||
"age=0": { "textures": { "crop": "bworld:wheat_stage_0" } },
|
||
"age=1": { "textures": { "crop": "bworld:wheat_stage_1" } }
|
||
},
|
||
"render_layer": "cutout",
|
||
"collision": false,
|
||
"states": [{ "name": "age", "bits": 3, "default": 0 }]
|
||
}
|
||
}
|
||
```
|
||
|
||
Mods add their own models in `models/`, in minecraft's block model format: boxes in pixels (0-16 is the block) with a
|
||
texture per face. `models/slab.json`:
|
||
|
||
```json
|
||
{
|
||
"format_version": 1,
|
||
"model": {
|
||
"id": "copper_tools:slab",
|
||
"textures": { "top": "#side", "bottom": "#side" },
|
||
"elements": [{
|
||
"from": [0, 0, 0],
|
||
"to": [16, 8, 16],
|
||
"faces": {
|
||
"top": { "texture": "#top" },
|
||
"bottom": { "texture": "#bottom", "cullface": "bottom" },
|
||
"north": { "texture": "#side", "cullface": "north" },
|
||
"south": { "texture": "#side", "cullface": "south" },
|
||
"west": { "texture": "#side", "cullface": "west" },
|
||
"east": { "texture": "#side", "cullface": "east" }
|
||
}
|
||
}]
|
||
}
|
||
}
|
||
```
|
||
|
||
| Field | Meaning |
|
||
| ---------------------------- | ---------------------------------------------------------------------------------------------- |
|
||
| `id` | The model's id, in the mod's namespace. |
|
||
| `textures` | Defaults for texture variables. Values are texture ids or other variables (`"#side"`). |
|
||
| `elements[].from`, `to` | Opposite corners of the box, `[x, y, z]` in pixels. |
|
||
| `elements[].rotation` | `{ origin, axis, angle, rescale }`: turns the box around `origin` on `x`, `y` or `z` by -45, -22.5, 0, 22.5 or 45 degrees. `rescale` stretches it back to the block's width. |
|
||
| `elements[].shade` | Darker faces on the sides and bottom, `true` by default. Plants turn it off. |
|
||
| `elements[].faces` | `top`, `bottom`, `north`, `south`, `west` and `east`. Faces are only drawn from the front, so a flat plane needs one face each way. |
|
||
| `faces.*.texture` | A variable (`"#side"`, filled in by the block's `textures`) or a texture id. |
|
||
| `faces.*.uv` | `[u1, v1, u2, v2]`, the part of the texture in pixels. Defaults to the part the face covers. |
|
||
| `faces.*.cullface` | Hidden when the neighbor on that side is a solid block. Only for faces on the block's edge. |
|
||
|
||
Faces flat against the block's edge get smooth lighting and ambient occlusion like a full block's. Anything inside the
|
||
block is lit evenly with the block's own light.
|
||
|
||
## Items
|
||
|
||
`items/copper_pickaxe.json`:
|
||
|
||
```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 }` | Used by the crafting grid, fits anywhere in it |
|
||
| `furnace` | `input` item id, `output` `{ id, count }`, `cook_time` in ticks | Used by the furnace |
|
||
| `fuel` | `item` id, `burn_time` in ticks | Used by the furnace |
|
||
| `smithing` | `tool` item id, `material` `{ id, count }`, optional `addition` item id, `result` item id | Used by the smithing table: upgrades the tool, keeping its data. Uses up the tool, `count` of the material and the addition |
|
||
|
||
A smithing recipe, `recipes/smithing_stone_pickaxe.json`:
|
||
|
||
```json
|
||
{
|
||
"format_version": 1,
|
||
"recipe": {
|
||
"type": "smithing",
|
||
"tool": "bworld:wood_pickaxe",
|
||
"material": { "id": "bworld:stone", "count": 5 },
|
||
"result": "bworld:stone_pickaxe"
|
||
}
|
||
}
|
||
```
|
||
|
||
A shaped recipe, for the crafting grid in the player's inventory screen:
|
||
|
||
```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`, two smithing recipes with the same tool, material and addition, or two
|
||
shaped recipes with the same pattern and key, are a load error. Mods can add recipe types of their own for their
|
||
machines, see [Common scripts](#common-scripts).
|
||
|
||
## Overrides
|
||
|
||
A mod can change content other mods registered, like Minetest's `override_item`. The JSON form covers the common cases,
|
||
and a [common script](#common-scripts) can do the same with `override` and `extend`. _Planned, phase 2._
|
||
|
||
`overrides/grass_weeds.json`:
|
||
|
||
```json
|
||
{
|
||
"format_version": 1,
|
||
"override": {
|
||
"block": "bworld:grass",
|
||
"set": { "mining": { "toughness": 1, "tool": "shovel" } },
|
||
"add_components": { "farming_plus:weeds": { "chance": 0.01 } }
|
||
}
|
||
}
|
||
```
|
||
|
||
| Field | Meaning |
|
||
| ------------------- | ---------------------------------------------------------------------------------------------------- |
|
||
| `block` or `item` | The id to change. |
|
||
| `set` | Fields to replace, with the same names as in the block or item JSON. Each listed field is replaced whole, so `mining` above replaces all of the old `mining`. |
|
||
| `add_components` | Components to add. One the block or item already has gets these params instead. |
|
||
| `remove_components` | Component ids to take away. |
|
||
|
||
A recipe override removes recipes, so a mod can replace them with its own:
|
||
|
||
```json
|
||
{
|
||
"format_version": 1,
|
||
"override": { "remove_recipes": { "type": "shaped", "result": "bworld:chest" } }
|
||
}
|
||
```
|
||
|
||
`remove_recipes` matches recipes whose fields equal the ones given (`result` matches the result's id).
|
||
|
||
Rules:
|
||
|
||
- A mod can only change content of mods it lists in `dependencies`, so it always loads after them. `check-mods` makes
|
||
this an error.
|
||
- Changes apply in load order, so when two mods change the same field the later one wins. `check-mods` warns about it.
|
||
- Ids never change, and blocks and items can't be removed, because saves refer to them. To make something unobtainable,
|
||
remove its recipes and drops.
|
||
- Overrides are content like everything else: they're applied on the server and every client, and are part of the
|
||
registry hash.
|
||
|
||
## Common scripts
|
||
|
||
`scripts.common` exports `setup`, called on the server and on every client while content loads, right after the mod's
|
||
own JSON is registered (see [Loading](#loading)). _Planned, phase 1._
|
||
|
||
```ts
|
||
import type { CommonContext } from "bworld/common";
|
||
|
||
const COLORS = ["white", "red", "green", "blue", "black"];
|
||
|
||
export function setup(ctx: CommonContext) {
|
||
for (const color of COLORS) {
|
||
ctx.blocks.register({
|
||
id: `wool:${color}_wool`,
|
||
textures: `wool:${color}_wool`,
|
||
mining: { toughness: 1 },
|
||
drops: `wool:${color}_wool`,
|
||
});
|
||
ctx.recipes.register({
|
||
type: "shaped",
|
||
pattern: ["D", "W"],
|
||
key: { D: `wool:${color}_dye`, W: "wool:white_wool" },
|
||
result: { id: `wool:${color}_wool`, count: 1 },
|
||
});
|
||
}
|
||
|
||
// wool burns in the base game's furnace
|
||
for (const color of COLORS) {
|
||
ctx.recipes.register({ type: "fuel", item: `wool:${color}_wool`, burn_time: 100 });
|
||
}
|
||
|
||
// every leaf block any mod registered drops sticks sometimes
|
||
for (const id of ctx.blocks.ids().filter((id) => id.endsWith("_leaves"))) {
|
||
ctx.blocks.extend(id, { components: { "wool:stick_drop": { chance: 0.05 } } });
|
||
}
|
||
}
|
||
```
|
||
|
||
```ts
|
||
interface CommonContext {
|
||
mod: { id: string; version: string };
|
||
blocks: ContentRegistry<BlockJson>;
|
||
items: ContentRegistry<ItemJson>;
|
||
models: ContentRegistry<ModelJson>;
|
||
recipes: RecipeRegistry;
|
||
log(...args: unknown[]): void;
|
||
}
|
||
|
||
// T is the same shape as the JSON file's content, checked the same way
|
||
interface ContentRegistry<T> {
|
||
register(definition: T): void;
|
||
get(id: string): Readonly<T> | undefined; // what's registered so far, by any mod
|
||
ids(): string[];
|
||
override(id: string, fields: Partial<T>): void; // like "set" in an override file
|
||
extend(id: string, change: { components?: Record<string, unknown>; remove_components?: string[] }): void;
|
||
}
|
||
|
||
interface RecipeRegistry {
|
||
register(recipe: RecipeJson): void;
|
||
remove(match: (recipe: RecipeJson) => boolean): number; // how many it removed
|
||
list(type: string): readonly RecipeJson[];
|
||
// a new recipe type: its recipes are checked with validate, and scripts read them with list
|
||
register_type(id: string, type: { validate(recipe: unknown): string[] }): void;
|
||
}
|
||
```
|
||
|
||
- Definitions are checked exactly like JSON files, and a problem is a load error naming the mod.
|
||
- Common scripts only run during loading. They can't keep state for later, and can't reach the world, players or
|
||
anything else: that's what server and client scripts are for. They get the same `ctx` shape on both sides on
|
||
purpose, so they can't accidentally register different things.
|
||
- Recipe types are ids like everything else. `shaped`, `furnace`, `fuel` and `smithing` are short for `bworld:shaped`
|
||
and so on, since the base game's crafting grid, furnace and smithing table use them. A mod's machine registers its own
|
||
type (`copper_tools:alloy`) and its server script reads the recipes with `ctx.recipes.list("copper_tools:alloy")`.
|
||
|
||
## Server scripts
|
||
|
||
`scripts.server` exports `setup`, called once when the server starts:
|
||
|
||
```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`.
|
||
|
||
`ctx` is the Game API for the server. Content is already registered and frozen by now (see [Loading](#loading)), so
|
||
server scripts read registries but don't change them.
|
||
|
||
```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;
|
||
items: ItemApi;
|
||
recipes: RecipeApi;
|
||
ui: ServerUi; // see GUIs
|
||
net: ServerNet; // see Mod channels
|
||
storage: ModStorage;
|
||
log(...args: unknown[]): void;
|
||
}
|
||
```
|
||
|
||
### Block and item components
|
||
|
||
Blocks and items get behavior from components registered here:
|
||
|
||
```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;
|
||
drop_item(x: number, y: number, z: number, item: ItemStack): void; // pops out of the block, like drops
|
||
is_loaded(x: number, z: number): boolean;
|
||
readonly seed: string;
|
||
readonly time: number; // world time, see below
|
||
set_time(time: number): void; // synced to every player
|
||
}
|
||
|
||
interface Player {
|
||
readonly id: string;
|
||
readonly name: string;
|
||
readonly position: { x: number; y: number; z: number };
|
||
readonly inventory: Container; // 36 slots, hotbar is 0-8
|
||
readonly selected_slot: number;
|
||
readonly held_item: ItemStack | undefined;
|
||
// what doesn't fit in the inventory drops at their feet
|
||
give_item(id: string, count?: number, data?: unknown): void;
|
||
send_message(text: string): void;
|
||
teleport(x: number, y: number, z: number): void;
|
||
}
|
||
|
||
interface PlayerList {
|
||
all(): Player[];
|
||
get(id: string): Player | undefined;
|
||
by_name(name: string): Player | undefined;
|
||
}
|
||
|
||
interface System {
|
||
run_timeout(fn: () => void, ticks: number): number;
|
||
run_interval(fn: () => void, ticks: number): number;
|
||
clear_run(handle: number): void;
|
||
readonly current_tick: number;
|
||
}
|
||
|
||
// small key value store per mod, saved with the world
|
||
interface ModStorage {
|
||
get<T>(key: string): T | undefined;
|
||
set(key: string, value: unknown): void; // JSON values only
|
||
delete(key: string): void;
|
||
}
|
||
```
|
||
|
||
**World time** counts ticks since 06:00 of day 1 and never wraps around. A day (06:00 to 18:00) is 36000 ticks, 30
|
||
minutes, and a night (18:00 to 06:00) is 12000 ticks, 10 minutes, so in game hours are shorter at night. Dusk is the
|
||
first minute of the night and dawn the last, so it's bright all day. `common/time.ts` has `clock`, `format_clock`,
|
||
`is_day` and `daylight` for turning it into something readable. Client scripts can read it as `ctx.world.time`, and
|
||
players can use `/time`, `/time set <ticks|day|noon|sunset|night|midnight>` and `/time add <ticks>`.
|
||
|
||
The server loads chunks around every player (its simulation distance) and generates them itself with the same generator
|
||
as the clients. Blocks in unloaded chunks can't be read or changed, and don't tick.
|
||
|
||
### Commands
|
||
|
||
```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`, `time` and `tps`, until phase 5 moves `give` and `time` into `mods/bworld`), is a load error. `/copper_tools:heal` always works as the unambiguous form.
|
||
|
||
## Client scripts
|
||
|
||
`scripts.client` exports `setup`, called after the client has downloaded the server's mods and before the world appears:
|
||
|
||
```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;
|
||
}
|
||
```
|
||
|
||
Only `ctx.player`, `ctx.world` and `ctx.log` work so far, the rest is phase 4.
|
||
|
||
Client scripts are for presentation. They can't change the world or inventories; they ask the server over a
|
||
[mod channel](#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)
|
||
|
||
_Planned, phase 4._
|
||
|
||
Simple dialogs defined entirely by server code, like Bedrock's `@minecraft/server-ui`. The client draws them with the
|
||
game's UI style. No client script is needed.
|
||
|
||
```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 base game's chest and furnace are built this way, see
|
||
`mods/bworld/scripts/server.ts`.
|
||
|
||
```ts
|
||
ctx.components.register_block("copper_tools:smelter", {
|
||
on_create(block) {
|
||
block.data = { container: ctx.containers.create(3).id, progress: 0 };
|
||
},
|
||
on_interact(block, _params, player) {
|
||
const container = ctx.containers.get(block.data.container)!;
|
||
const screen = ctx.ui.open_container(player, {
|
||
container,
|
||
layout: [
|
||
{ slot: 0, x: 3, y: 0, filter: "smeltable" },
|
||
{ slot: 1, x: 3, y: 2, filter: (item) => ctx.recipes.is_fuel(item.id) },
|
||
{ slot: 2, x: 5, y: 1, output_only: true },
|
||
],
|
||
bars: [{
|
||
x: 4,
|
||
y: 1,
|
||
value: "progress",
|
||
max: "progress_max",
|
||
direction: "right",
|
||
empty_texture: "bworld:arrow_empty",
|
||
full_texture: "bworld:arrow_full",
|
||
}],
|
||
});
|
||
screen.set_property("progress", block.data.progress);
|
||
screen.set_property("progress_max", 200);
|
||
return true;
|
||
},
|
||
on_break(block) {
|
||
ctx.containers.delete(block.data.container);
|
||
},
|
||
});
|
||
```
|
||
|
||
- The screen is drawn below the player's inventory and hotbar. `x` / `y` are in slot units in the screen's own area and
|
||
can be fractional. `rows` sets its height, by default it fits the slots and bars.
|
||
- `filter` is a function `(item) => boolean` or one of the built-in filters `"smeltable"` (has a furnace recipe) and
|
||
`"fuel"`. It and `output_only` run **on the server**, so players can't put the wrong items in by editing their client.
|
||
`output_only` slots can only be taken from, all at once, like the furnace result. Their `on_take(item)` runs when a
|
||
player takes from one, before they get it: return `false` to stop them, or use up the inputs there so they're gone in
|
||
the same step (the smithing table does this).
|
||
- `bars` are progress bars filled with `value / max`, both names of properties set with `screen.set_property(id, n)`.
|
||
`direction` is `"up"` (like the furnace's fire) or `"right"` (like its arrow). Properties are synced when they change,
|
||
so a component can update them every tick.
|
||
- The screen handle has `player`, `open`, `set_property`, `close()` and `on_close(fn)`. It closes when the player closes
|
||
it, leaves, or opens another screen, and when its container is deleted. Changes to the container (from scripts,
|
||
other players) show up for everyone viewing it.
|
||
- Containers are saved with the world until they're deleted, so a block that has one deletes it in `on_break`.
|
||
|
||
```ts
|
||
interface ContainerApi {
|
||
create(size: number): Container; // 1 to 256 slots, saved with the world
|
||
get(id: string): Container | undefined;
|
||
delete(id: string): void; // closes screens showing it
|
||
}
|
||
|
||
interface Container {
|
||
readonly id: string;
|
||
readonly size: number;
|
||
get(slot: number): ItemStack | undefined;
|
||
set(slot: number, item: ItemStack | undefined): void;
|
||
add(item: ItemStack): ItemStack | undefined; // returns what didn't fit
|
||
on_change(fn: (slot: number) => void): () => void;
|
||
}
|
||
|
||
interface ItemApi {
|
||
exists(id: string): boolean;
|
||
max_stack(id: string): number;
|
||
}
|
||
```
|
||
|
||
Items handed to scripts are live: setting `count` changes the stack, and a stack counted down to 0 is gone.
|
||
|
||
### 3. Custom screens (client code)
|
||
|
||
_Planned, phase 4._
|
||
|
||
For anything forms and containers can't do: maps, skill trees, minigames, custom layouts. The client script registers a
|
||
screen class, and the server opens it with some props:
|
||
|
||
```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
|
||
|
||
_Planned, phase 4._
|
||
|
||
```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
|
||
|
||
_Planned, phase 4._ 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, water and trees.
|
||
2. **Ores.** Every mod's `worldgen/ores.json`, in load order.
|
||
3. **Features.** Every mod's registered features, in load order.
|
||
|
||
The world is 256 blocks tall and the sea is at y 64 (`CHUNK_HEIGHT` and `SEA_LEVEL` in `common/constants.ts`). The
|
||
base overworld lives in `common/worldgen/` until phase 6 moves it into `mods/bworld`. It works like Minecraft 1.18+ and
|
||
the Terralith datapack: continentalness, erosion and weirdness noises feed nested splines that give every column a
|
||
height, jaggedness and roughness, a 3D density around that height is sampled on a coarse grid and interpolated, and
|
||
caves are cut out of it. On top of that come Terralith-style shapes: terraced plateaus, shattered hills, river valleys
|
||
and gorges, jagged peaks and rare sky islands. Its biome ids (`biome_at`) are the keys of `BIOMES` in
|
||
`common/worldgen/overworld.ts`, like `bworld:yosemite_cliffs` or `bworld:skylands`. Trees
|
||
(`common/worldgen/trees.ts`) are spread out like Poisson disk sampling, never closer than 4 blocks, and each biome sets
|
||
how many grow and which kinds. `deno run -A tools/worldgen_preview.ts [seed]` renders it to PNG files.
|
||
|
||
### Terrain generators
|
||
|
||
A world uses exactly one terrain generator, registered by a worldgen script. The base game's will be `bworld:overworld`
|
||
(phase 6); until then `register_terrain` throws and the engine's own overworld is used.
|
||
Which one a world uses is saved with the world. A new world uses the only one installed, or the server's `TERRAIN`
|
||
setting when there are several. A server with no terrain generator refuses to start.
|
||
|
||
```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.
|
||
|
||
## Engine access
|
||
|
||
For what the Game API doesn't cover yet: a new kind of rendering, different player physics, a change to how lighting
|
||
spreads, an entity type. Like Fabric's mixins, a mod can reach the engine's own classes and hook their methods, on the
|
||
server and on the client. It's powerful and unstable at once, so a mod has to ask for it. _Planned, phase 3._
|
||
|
||
```json
|
||
{ "engine_access": true, "game_version": "~0.4.0" }
|
||
```
|
||
|
||
```ts
|
||
// client.ts: an outline around every dropped item
|
||
import type { ClientContext } from "bworld/client";
|
||
import { engine, hook } from "bworld/engine";
|
||
|
||
export function setup(ctx: ClientContext) {
|
||
hook(engine.client.LevelRenderer.prototype, "render_entities", {
|
||
after(renderer, [level, camera_entity, partial_tick]) {
|
||
for (const entity of level.entities.values()) {
|
||
if (entity instanceof engine.client.ItemEntity) draw_outline(entity, partial_tick);
|
||
}
|
||
},
|
||
});
|
||
}
|
||
```
|
||
|
||
```ts
|
||
// server.ts: broken blocks drop twice
|
||
import { engine, hook } from "bworld/engine";
|
||
|
||
export function setup() {
|
||
hook(engine.server.GameServer.prototype, "pop_item", {
|
||
after(game, [x, y, z, stack], entity) {
|
||
game.spawn_item(entity.x, entity.y, entity.z, stack.clone());
|
||
return entity;
|
||
},
|
||
});
|
||
}
|
||
```
|
||
|
||
```ts
|
||
// client.ts: everything falls at half speed. physics is an object so its functions can be hooked, see below
|
||
import { engine, hook } from "bworld/engine";
|
||
|
||
export function setup() {
|
||
hook(engine.common.physics, "move_body", {
|
||
before(_physics, args) {
|
||
args[1] /= 2; // gravity
|
||
},
|
||
});
|
||
}
|
||
```
|
||
|
||
### What `bworld/engine` gives
|
||
|
||
- `engine` holds the engine's modules as they run on that side: `engine.common` (registries, constants, inventory,
|
||
physics, generation, the protocol types) everywhere, `engine.server` (`GameServer`, `ServerWorld`, `ModRuntime`, the
|
||
running `game`) in server scripts, and `engine.client` (`Client`, `ClientLevel`, the renderer, GUI classes, the running
|
||
`client`) in client scripts. Worldgen scripts get `engine.common` only.
|
||
- They're the live classes and objects the game uses, not copies: the engine puts them on the global object at startup,
|
||
and `bworld/engine` in a mod's bundle is a small shim that reads them. So a hook on a prototype changes every instance
|
||
the engine already made.
|
||
- The types are the engine's own source, so `check-mods` typechecks a mod against exactly the engine it will run on, and
|
||
an engine change that breaks a mod shows up there first.
|
||
|
||
### Hooks
|
||
|
||
```ts
|
||
function hook<T, K extends keyof T>(target: T, method: K, handlers: {
|
||
// runs first. change args in place, or return { result } to skip the method and everything after
|
||
before?(self: T, args: Parameters<T[K]>): { result: ReturnType<T[K]> } | void;
|
||
// wraps the method, calling original (or not)
|
||
replace?(self: T, args: Parameters<T[K]>, original: T[K]): ReturnType<T[K]>;
|
||
// runs last, and returns the result the caller gets
|
||
after?(self: T, args: Parameters<T[K]>, result: ReturnType<T[K]>): ReturnType<T[K]>;
|
||
// lower runs earlier, the default is 1000. ties go in load order
|
||
priority?: number;
|
||
}): () => void; // unhook
|
||
```
|
||
|
||
- Hooks on a prototype apply to every instance, hooks on one object only to it.
|
||
- Several mods can hook the same method. `before` hooks run in priority order, `replace` hooks nest (the first one's
|
||
`original` is the next one's wrapper, the last one's is the engine's method), and `after` hooks run in priority order,
|
||
each getting the previous one's result.
|
||
- A hook that throws is logged under its mod and skipped, like event handlers. The method still runs.
|
||
- Functions exported from a module can't be hooked, because a module's exports can't be reassigned. The engine puts
|
||
functions mods may want to change on plain objects (like `engine.common.physics`) and calls them through those objects
|
||
itself, so a hook changes what the engine calls. Classes' methods are hookable on their prototype.
|
||
- JavaScript's `#private` members can't be hooked or read. The engine keeps things that mods should be able to change
|
||
as normal members, and `#private` for things with rules that must hold (like a container's slots). Making something
|
||
hookable is an ordinary engine change, and asking for one is how the Game API grows.
|
||
- Hooks reach one JavaScript realm: the game server worker for server scripts, the page for client scripts, and one
|
||
chunk worker for worldgen scripts. Each chunk worker runs its own copy of the engine, so a hook in a client script
|
||
doesn't change meshing; a worldgen script with engine access can.
|
||
|
||
### Rules
|
||
|
||
- **It isn't a stable API.** Any game update can rename or change what a hook targets. A mod with `engine_access` must
|
||
set `game_version` to a range within one minor version (like `~0.4.0`). Loading it on another version is a load error
|
||
naming the mod, instead of a crash somewhere later.
|
||
- **Prefer the Game API.** When it can do something, it keeps working across updates. Engine access is for what it
|
||
can't do yet, and a mod that hooks the same thing as many others is a sign it should become part of the Game API.
|
||
- Without `engine_access`, importing `bworld/engine` is a `check-mods` error and a load error.
|
||
- See [Security](#security) for what it means for players and servers.
|
||
|
||
## Delivery to clients
|
||
|
||
### Mod files
|
||
|
||
A mod is shipped as a `.bmod` file: the mod, built, in one zip. Servers load every `.bmod` in their `server_mods/`
|
||
folder, so installing a mod is dropping its file there and restarting.
|
||
|
||
```sh
|
||
deno task pack-mod mods/copper_tools # writes copper_tools.bmod
|
||
deno task build # packs every mod in mods/ into server_mods/<id>.bmod
|
||
```
|
||
|
||
```
|
||
copper_tools.bmod
|
||
manifest.json # the mod's manifest, with scripts and credits pointing into the zip
|
||
data.json # every block, model, item, recipe and ore, checked when it was packed
|
||
scripts/server.js # each script bundled into one module
|
||
scripts/client.js
|
||
scripts/worldgen.js
|
||
textures/<name>.png # the texture copper_tools:<name>
|
||
credits.md
|
||
```
|
||
|
||
- A mod is a Deno project. When its folder has a `deno.json`, its scripts are bundled with it, so its own imports and
|
||
npm or JSR packages work and end up in the bundle. Mods without one use the `deno.json` of the folder the build runs
|
||
in, like the ones in this repository.
|
||
- Packing checks the mod first, like `check-mods`, and refuses to pack one with errors.
|
||
- Packing the same mod twice gives the same bytes, so the file's hash only changes when the mod does.
|
||
- `deno task build` leaves other `.bmod` files in `server_mods/` alone, so mods from elsewhere can sit next to the ones
|
||
it builds.
|
||
|
||
When the server starts it opens every `.bmod`, checks it again (it may come from anyone): its manifest and data, its
|
||
textures, the ids it uses from other mods, and that its dependencies are there. A problem in any mod stops the server
|
||
from starting, saying which mod and what's wrong, so the game never runs with some mods missing. Then it sorts them so
|
||
dependencies load first.
|
||
|
||
Players get each mod as a `.bmod` too, **without its server script**: the server makes a copy without
|
||
`scripts/server.js` and serves it at `/mods/<sha256>.bmod`, named by its hash so it can be cached forever. The code is in
|
||
`common/bmod.ts` (reading and writing), `tools/pack_mod.ts` and `server/load_bmods.ts`.
|
||
|
||
The engine's own files (the client, its UI sprites and font, and the `engine:` textures) aren't in any mod. They're in
|
||
`build/`, which the server serves as it is.
|
||
|
||
### Joining
|
||
|
||
```
|
||
client server
|
||
│ hello { name, protocol } │
|
||
├────────────────────────────────────────►│ a different protocol gets rejected { reason }
|
||
│ welcome { protocol, seed, mods[] } │ mods[i] = { id, name, version, sha256, file, size }
|
||
│◄────────────────────────────────────────┤ file is the .bmod's path on the server
|
||
│ │
|
||
│ [cross-origin: confirm screen] │
|
||
│ download every .bmod, check each one's │
|
||
│ sha256, build the texture atlas, load │
|
||
│ content in the listed order, run │
|
||
│ client setup(), start chunk workers │
|
||
│ │
|
||
│ ready { registry_hash } │ a different hash gets rejected { reason }
|
||
├────────────────────────────────────────►│
|
||
│ join { id, name, players, entities, │ the player is created here, player_join fires,
|
||
│ changes, spawn, selected_slot } │ and their inventory follows as container messages
|
||
│◄────────────────────────────────────────┤
|
||
```
|
||
|
||
- The client registers each mod's data **in the order the server lists them**.
|
||
- Every `.bmod` is checked against its SHA-256 before it's opened, 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`.
|
||
- The client builds the texture atlas itself, from the engine's textures and the ones in the `.bmod` files
|
||
(`client/atlas.ts`). The server never needs to decode an image.
|
||
- 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.
|
||
- `.bmod` files are served with `Access-Control-Allow-Origin: *` and cached for a year, since their paths change
|
||
whenever their content does. The engine's assets are served with `Access-Control-Allow-Origin: *` too, for pages
|
||
from other origins.
|
||
- Leaving a server reloads the page, so one server's mod code never stays loaded while playing on another.
|
||
|
||
## Security
|
||
|
||
**Client scripts.** Mod client code runs in the game page with the page's full access. How much that matters depends on
|
||
where the page came from:
|
||
|
||
- **Page served by the game server** (the default): that server already chose every line of JavaScript on the page, so
|
||
running its mods doesn't trust it any more than loading the page did.
|
||
- **Page from one origin, game server on another** (a different server typed in the title screen, or `?server=`): mod
|
||
code from that server runs with _the page's_ origin, including its local storage. The client shows the server's mod
|
||
list and asks before loading anything, and remembers the answer per server and mod hash. The server needs CORS
|
||
headers on its `.bmod` downloads and assets, which it sends.
|
||
- The hash check confirms the files are the ones the server listed. It doesn't protect against a malicious server.
|
||
|
||
**Engine access.** A server mod with engine access controls the game server worker completely, but the worker still
|
||
has no Deno permissions, so it can't reach files or the network any more than other server scripts. A client mod with
|
||
engine access changes nothing about what it _can_ reach, since client scripts already run with the page's full access,
|
||
but it can change how the game behaves for players in ways the Game API can't. The confirm screen for other servers
|
||
lists which mods use engine access.
|
||
|
||
**Server scripts.** They run in a worker with **no Deno permissions** (Deno worker permissions; currently needs
|
||
`--unstable-worker-options`). They can't read files, open connections or run programs. Everything they need goes through
|
||
the mod API. Installing a server mod still means trusting it with the game world and everything players send.
|
||
|
||
**Server scripts stay on the server.** Players get each mod's `.bmod` without its server script, so a mod can keep
|
||
anti-cheat logic or anything else private there.
|
||
|
||
**Players.** Clients are untrusted. The server checks reach and the item being held for every break, place and interact,
|
||
applies container rules on the server, validates form responses, and rate-limits mod channels. Server script code and
|
||
tile data are never sent to clients.
|
||
|
||
## Example mod
|
||
|
||
A smelter block with a container screen, a stats screen written in client code, and a channel between them.
|
||
|
||
`mods/copper_tools/manifest.json`
|
||
|
||
```json
|
||
{
|
||
"format_version": 1,
|
||
"id": "copper_tools",
|
||
"name": "Copper Tools",
|
||
"version": "1.0.0",
|
||
"dependencies": [{ "id": "bworld", "version": "*" }],
|
||
"scripts": { "server": "scripts/server.ts", "client": "scripts/client.ts" }
|
||
}
|
||
```
|
||
|
||
`mods/copper_tools/blocks/smelter.json`
|
||
|
||
```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, {
|
||
container: ctx.containers.get(data.container)!,
|
||
layout: [
|
||
{ slot: 0, x: 3, y: 0, filter: "smeltable" },
|
||
{ slot: 1, x: 3, y: 2, filter: "fuel" },
|
||
{ slot: 2, x: 5, y: 1, output_only: true },
|
||
],
|
||
bars: [{
|
||
x: 4,
|
||
y: 1,
|
||
value: "progress",
|
||
max: "progress_max",
|
||
direction: "right",
|
||
empty_texture: "bworld:arrow_empty",
|
||
full_texture: "bworld:arrow_full",
|
||
}],
|
||
}).set_property("progress", data.progress);
|
||
return true;
|
||
},
|
||
on_tick(block, params) {
|
||
const data = block.data as SmelterData;
|
||
// ... smelt using ctx.recipes, data.progress += params.speed, data.smelted += 1 when done
|
||
},
|
||
});
|
||
|
||
ctx.commands.register("smelterstats", {
|
||
description: "Show stats for the smelter you're looking at",
|
||
usage: "/smelterstats",
|
||
run(_args, player) {
|
||
ctx.ui.open_screen(player, "copper_tools:smelter_stats", { smelted: count_all_smelted() });
|
||
},
|
||
});
|
||
|
||
ctx.net.on("copper_tools:reset_stats", (player) => {
|
||
if (player.name !== ctx.storage.get("owner")) return;
|
||
reset_all_smelted();
|
||
});
|
||
}
|
||
```
|
||
|
||
`mods/copper_tools/scripts/client.ts`
|
||
|
||
```ts
|
||
import type { ClientContext, Graphics, ModScreen } from "bworld/client";
|
||
|
||
export function setup(ctx: ClientContext) {
|
||
ctx.ui.register_screen("copper_tools:smelter_stats", (props) => new SmelterStats(ctx, props));
|
||
}
|
||
|
||
class SmelterStats implements ModScreen {
|
||
constructor(private ctx: ClientContext, private props: { smelted: number }) {}
|
||
|
||
on_render(g: Graphics) {
|
||
const x = g.width / 2 - 150;
|
||
const y = g.height / 2 - 80;
|
||
g.panel(x, y, 300, 160);
|
||
g.text(`Smelted: ${this.props.smelted}`, x + 20, y + 20, { scale: 2 });
|
||
if (g.button("Reset", x + 90, y + 100, 120, 32)) {
|
||
this.ctx.net.send("copper_tools:reset_stats", {});
|
||
}
|
||
}
|
||
|
||
on_props(props: { smelted: number }) {
|
||
this.props = props;
|
||
}
|
||
}
|
||
```
|
||
|
||
## The engine and the base game
|
||
|
||
Like a Minetest game, bworld is the engine plus one mod, `mods/bworld`, written against the same API as any other mod.
|
||
This is how the API gets tested for real: if the base game can't be written as a mod, the API is missing something, and
|
||
other mods would hit the same wall. It also means there's one way content works, so mods can do anything the base game
|
||
does, and change any of it.
|
||
|
||
**The engine** keeps what every game built on it needs: rendering, meshing, lighting and the sky; physics, player
|
||
movement and input; networking, saving, the game server and its loop; registries, the mod loader and the Game API;
|
||
containers, slot click rules, screens and the GUI toolkit; item entities; chat; world time; `/tps`; `bworld:air`; and
|
||
the engine assets in `assets/` (UI sprites, font, player sprite, and the `engine:` textures and block models).
|
||
|
||
**`mods/bworld`** has everything else:
|
||
|
||
| What | Where it is now | Status |
|
||
| ---------------------------------------------------- | ----------------------------------- | ----------------------- |
|
||
| Blocks, items, models, recipes, textures, credits | `mods/bworld` JSON and `textures/` | Done |
|
||
| Hoeing, chest, furnace, smithing table, crops, watering can | `mods/bworld/scripts/server.ts` | Done |
|
||
| Recipe types `shaped`, `furnace`, `fuel`, `smithing` | engine (`common/mod_loader.ts`, `server/game/crafting.ts`) | Phase 5 |
|
||
| The player's inventory screen and crafting grid | engine (`client/gui/gui_player_inventory.ts`, `server/game/crafting.ts`) | Phase 5 |
|
||
| The hotbar and crosshair | engine (`client/gui/hud.ts`) | Phase 5 |
|
||
| `/give` and `/time` | engine (`server/game/game_server.ts`) | Phase 5 |
|
||
| Mining: tool speed, `requires_tool`, drops | engine (`client/game_mode.ts`, `server/game/game_server.ts`) | Phase 5 |
|
||
| Day and night lengths, sky colors | engine (`common/time.ts`, `client/rendering/game_renderer.ts`) | Phase 5 |
|
||
| Where new players spawn | engine (`server/game/world.ts`) | Phase 5 |
|
||
| Terrain, biomes, trees and ores | engine (`common/worldgen/`, `common/generation.ts`) | Phase 6 |
|
||
|
||
### Rules that keep existing worlds working
|
||
|
||
Worlds saved before any of this must load afterwards with nothing changed:
|
||
|
||
1. **Ids don't change.** Saves store blocks and items by string id, including player inventories and chest contents.
|
||
2. **Terrain is identical, block for block.** A save only stores what players changed _on top of_ generated terrain. If
|
||
the generator's output changes at all, every existing world silently changes with it: trees move and blocks players
|
||
broke reappear. The golden terrain test (phase 6) enforces this.
|
||
3. **Old saves are upgraded when loading.** The save format can change when it has to, like version 3 did for
|
||
containers, as long as older saves load into the new shape. `tests/fixtures/` keeps a save from each version.
|
||
4. **The protocol can change, with `PROTOCOL_VERSION`.** Clients on another version are asked to reload before anything
|
||
else happens.
|
||
|
||
## Implementation plan
|
||
|
||
**Done so far:** the mod loader, `.bmod` mod files (packing, loading them from `server_mods/`, sending players a copy
|
||
without the server script), delivery to clients (hashed downloads, the confirm screen for other servers, credits); the data layer (blocks, block models with variants, items, recipes, ores); server scripts with components,
|
||
events, commands, timers, storage, recipes, containers and container screens; worldgen features and ores; the base
|
||
game's block behavior as a mod; world time with day and night. The code is in `common/mod_loader.ts`,
|
||
`common/mod_data.ts`, `server/game/mod_runtime.ts`, `client/mods.ts` and `common/worldgen_loader.ts`.
|
||
|
||
Each phase ends with every test passing and the game playable. Phases 1 to 3 are the foundation; 4 to 6 can happen in
|
||
any order after them.
|
||
|
||
**Phase 1: loading and common scripts.**
|
||
|
||
- `scripts.common`, bundled like the client script, run on the server and on clients between each mod's data and the
|
||
next mod.
|
||
- `CommonContext` with `ctx.blocks`, `ctx.items`, `ctx.models` and `ctx.recipes` (`register`, `get`, `ids`). The JSON
|
||
loader goes through the same functions, so there's one path for content.
|
||
- The freeze: registries close after content loads, and registering later throws. Numeric ids, block items and recipe
|
||
indexes are made at the freeze, not while registering.
|
||
- The registry hash: both sides hash their registries after the freeze, the client sends its hash with `ready`, and a
|
||
mismatch turns the player away naming the first difference.
|
||
- Recipe types become ids with `register_type` and `list`, with `shaped`, `furnace`, `fuel` and `smithing` as short
|
||
names for the base game's. Server scripts get `ctx.recipes.list(type)`.
|
||
- Check: a test mod registers blocks, items and recipes from code, and the server and a client end up with equal
|
||
registries and hashes. A common script that registers something different on each side is caught by the hash.
|
||
|
||
**Phase 2: overrides.**
|
||
|
||
- `override` and `extend` in common scripts, and `overrides/*.json` for the same thing as data. `recipes.remove`.
|
||
- `check-mods`: changing content of a mod that isn't a dependency is an error, two mods changing the same field is a
|
||
warning.
|
||
- Check: a test mod adds a component to `bworld:grass`, changes `bworld:glass`'s toughness and replaces the chest
|
||
recipe, and all of it works on both sides and in the registry hash.
|
||
|
||
**Phase 3: engine access.**
|
||
|
||
- The engine puts its modules on the global object at startup on each side (page, game server worker, chunk workers),
|
||
and the build turns `bworld/engine` imports into a shim that reads them. Types come from the engine's source.
|
||
- `hook` with `before`, `replace`, `after`, priorities and unhooking.
|
||
- `engine_access` in the manifest: `check-mods` and the loader refuse `bworld/engine` imports without it, and refuse
|
||
loading on a game version outside the mod's range. The confirm screen and the join screen say which mods use it.
|
||
- Go through the engine for things mods will want to change and make them hookable: move module functions like
|
||
`move_body` onto objects the engine calls through, and turn `#private` members that don't protect a rule into normal
|
||
ones.
|
||
- Check: test mods hook a server method, a client method and a worldgen method, several hooks on one method run in
|
||
priority order, and a throwing hook is logged without breaking the game.
|
||
|
||
**Phase 4: the rest of the Game API.**
|
||
|
||
- Mod channels (`ctx.net` on both sides, `{ type: "mod", channel, data }`, the size and rate limits).
|
||
- Keybinds (`ctx.input.bind`), forms (`message_form`, `action_form`, `modal_form`), custom screens with `Graphics`, and
|
||
HUD elements.
|
||
- More events: items picked up and dropped, containers clicked, players moving between chunks, entities.
|
||
- Check: the example mod below works end to end, and every server event has a test.
|
||
|
||
**Phase 5: gameplay out of the engine.**
|
||
|
||
- `/give` and `/time` become commands in `mods/bworld`. `/tps` stays, it's about the engine.
|
||
- The crafting grid's matching and the `shaped` recipe type move into `mods/bworld`, and the player's inventory screen
|
||
becomes a screen `mods/bworld` registers, opened by the engine's inventory key.
|
||
- The hotbar and crosshair become `mods/bworld` HUD elements.
|
||
- Mining rules (how long breaking takes, tool speed, `requires_tool`, what drops) become something `mods/bworld` sets,
|
||
with the engine asking it on both sides so clients still predict breaking.
|
||
- The day and night lengths and sky colors become settings `mods/bworld` makes in its common script. The engine keeps
|
||
counting ticks.
|
||
- Where new players spawn becomes `mods/bworld`'s choice.
|
||
- Check: all tests pass unchanged, and `world_v2.json` and a version 3 save still load.
|
||
|
||
**Phase 6: world generation.**
|
||
|
||
- First the golden fixture: `tests/fixtures/terrain.json`, a SHA-256 of the final blocks of 64 chunks for 3 seeds,
|
||
including negative coordinates and chunks with trees on their borders, recorded from the current code.
|
||
- Port `common/worldgen/` to `mods/bworld/scripts/worldgen.ts` as the `bworld:overworld` terrain generator. It only uses
|
||
named noises, which are exactly `noise_2d` / `noise_3d` in [Terrain generators](#terrain-generators).
|
||
- Move the ore table to `mods/bworld/worldgen/ores.json`, and take the content out of `common/generation.ts`, leaving
|
||
the passes and noise helpers.
|
||
- The server refuses to start without a terrain generator, naming the mods that could provide one.
|
||
- Check: `terrain.json` matches exactly, client and server terrain still agree, and old saves load unchanged.
|
||
|
||
**Done when:**
|
||
|
||
- Searching `client/`, `common/` and `server/` for `"bworld:` finds only `bworld:air`.
|
||
- Removing `mods/bworld` gives an engine that starts and says it needs a terrain generator, instead of crashing
|
||
somewhere.
|
||
- The base game uses only the Game API, no engine access. If it needs a hook for something, that thing belongs in the
|
||
Game API.
|
||
- All tests pass, the golden fixtures are unchanged, and a world saved before any of this plays exactly the same.
|
||
|
||
## Open questions
|
||
|
||
- **Chunk delivery.** Clients generate terrain from the seed, which needs deterministic worldgen and trusts clients to
|
||
run the same generator. Sending chunks from the server instead (like Minecraft) removes both problems but costs
|
||
bandwidth, roughly 5–15 KB per chunk compressed. It could be worth it once the server generates terrain anyway.
|
||
- **Client-only mods.** Should players be able to install their own client mods (minimaps, UI tweaks, shaders) that work
|
||
on any server, like Fabric's client mods? They'd need a separate `"side": "client"` kind of mod that can't add content
|
||
or change the registry hash, and servers might want to forbid them.
|
||
- **Client-side prediction for mods.** Custom block components only run on the server, so interacting with a mod block
|
||
always waits one round trip. Components could get optional handlers in common scripts that clients run to predict.
|
||
- **Engine access versions.** Tying engine access mods to one minor version is simple but strict. Fabric gets further
|
||
with mapped names and mixin error reporting; how much of that is worth building depends on how often the engine
|
||
changes under mods.
|
||
- **Game API versions.** The Game API only breaking with the game's major version is a promise that needs deprecation
|
||
warnings and a changelog to keep.
|
||
- **Screen scaling.** The GUI currently works in raw canvas pixels (`SLOT_SIZE` is 54). `Graphics` should probably use a
|
||
UI scale the player can change, with screens laid out in scaled units.
|
||
- **Entities.** The only entities are players and dropped items, so mob mods need entity types in the Game API first.
|
||
- **Hot reload.** Restarting the server and reconnecting is the version 1 answer.
|