1163 lines
56 KiB
Markdown
1163 lines
56 KiB
Markdown
# 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 }`. |
|
||
| `transparent` | `false` | `transparent` | Drawn in the transparent pass, neighbors' faces stay visible. |
|
||
| `alpha` | `1` | `alpha` | Opacity for transparent blocks. |
|
||
| `collision` | `true` | `has_collision` | Whether entities collide with it. |
|
||
| `mining.toughness` | unbreakable | `toughness` | Seconds to break by hand. A matching tool is 2× faster. |
|
||
| `mining.tool` | none | `tool_to_break` | Tool type that speeds it up, like `pickaxe`, `axe` or `shovel`. |
|
||
| `mining.requires_tool` | `false` | `requires_tool` | Only drops when broken with `mining.tool`. |
|
||
| `drops` | nothing | `drop_table` | Item id given when broken. |
|
||
| `item` | `true` | `register_block_item` | Also register an item that places this block, with the same id. |
|
||
| `interactive` | `false` | `interactive` | Right clicking it does something (opens a screen), so clients don't guess it places a block. |
|
||
| `replaceable` | `false` | new | Placing a block into it replaces it, like water. |
|
||
| `states` | none | `states` | Up to 16 bits of named state, like `hoed_dirt` has now. |
|
||
| `components` | none | the `on_*` hooks | Custom components with parameters, handled by server scripts. |
|
||
|
||
The client needs this data too (for meshing, mining time and collision), so it's sent to every player. It can't contain
|
||
functions.
|
||
|
||
Block states work the same as today: `[{ "name": "facing", "bits": 2, "default": 0 }]`. They can't change textures yet,
|
||
because the mesher ignores them. `variants` is reserved for that.
|
||
|
||
## Items
|
||
|
||
`items/copper_pickaxe.json`:
|
||
|
||
```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 left clicks it. | ignored |
|
||
| `on_interact(block, params, player)` | A player right clicks it. | `true` if handled, so no block gets placed |
|
||
| `on_tick(block, params, dt)` | Every tick (20 per second) while its chunk is loaded. | ignored |
|
||
| `on_second(block, params, dt)` | Every second while its chunk is loaded. | ignored |
|
||
|
||
`block` is `{ id, x, y, z, data }`. `data` is tile data: any JSON value, `undefined` until a handler sets it, saved with
|
||
the world and **never sent to clients**. To show tile data to a player, open a screen with it (see [GUIs](#guis)).
|
||
`on_tick` and `on_second` only run for blocks that have tile data, like today.
|
||
|
||
When a block lists several components, each handler runs in the listed order. `on_interact` counts as handled if any
|
||
component returns `true`.
|
||
|
||
Item components use
|
||
`ctx.components.register_item(id, { on_create(item, params), get_lore(item, params), on_use(item,
|
||
params, player) })`.
|
||
|
||
### Events
|
||
|
||
**Before events** fire before something happens. Handlers can read and change the event, or set `cancel = true` to stop
|
||
it. They can't change the world themselves; use the after event or `system.run_timeout(fn, 0)` for that. **After
|
||
events** fire once it has happened.
|
||
|
||
| Event | before | after | Payload |
|
||
| ---------------- | ------ | ----- | -------------------------------------------------------- |
|
||
| `block_break` | ✓ | ✓ | `player`, `block`, `item` |
|
||
| `block_place` | ✓ | ✓ | `player`, `block`, `face`, `item` |
|
||
| `block_interact` | ✓ | ✓ | `player`, `block`, `item` |
|
||
| `chat_send` | ✓ | ✓ | `player`, `message` (before events can change `message`) |
|
||
| `player_join` | | ✓ | `player` |
|
||
| `player_leave` | | ✓ | `player` |
|
||
| `server_start` | | ✓ | none |
|
||
| `tick` | | ✓ | `dt` in seconds, 20 times per second |
|
||
|
||
These fire for **every** player's actions, because they all go through the server. When a before event cancels a break
|
||
or place, the server tells that player's client to undo what it already showed.
|
||
|
||
### World, players, system, storage
|
||
|
||
```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;
|
||
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 a base game
|
||
name like `give`, is a load error. `/copper_tools:heal` always works as the unambiguous form.
|
||
|
||
## Client scripts
|
||
|
||
`scripts.client` exports `setup`, called after the client has downloaded the server's mods and before the world appears:
|
||
|
||
```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, water and trees.
|
||
2. **Ores.** Every mod's `worldgen/ores.json`, in load order.
|
||
3. **Features.** Every mod's registered features, in load order.
|
||
|
||
### Terrain generators
|
||
|
||
A world uses exactly one terrain generator, registered by a worldgen script. The base game's is `bworld:overworld`.
|
||
Which one a world uses is saved with the world. A new world uses the only one installed, or the server's `TERRAIN`
|
||
setting when there are several. A server with no terrain generator refuses to start.
|
||
|
||
```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 `ORES` table in `common/generation.ts`, plus the block they replace:
|
||
|
||
```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 } │
|
||
├────────────────────────────────────────►│
|
||
│ welcome { seed, atlas, mods[] } │ mods[i] = { id, name, version, hash,
|
||
│◄────────────────────────────────────────┤ data, client?, worldgen? } (urls)
|
||
│ │
|
||
│ [cross-origin: confirm screen] │
|
||
│ fetch data, atlas, scripts │
|
||
│ check hashes, register data, │
|
||
│ run client setup(), start workers │
|
||
│ │
|
||
│ ready │
|
||
├────────────────────────────────────────►│
|
||
│ join { players, changes, inventory } │ player_join fires on the server here
|
||
│◄────────────────────────────────────────┤
|
||
```
|
||
|
||
- The client registers the base game, then each mod's data **in the order the server lists them**.
|
||
- If any download, hash check or `setup` fails, the client disconnects and shows which mod failed. It never joins with
|
||
some mods missing.
|
||
- The `protocol` in `hello` is the game's protocol version. A mismatch is rejected before any mod code is downloaded.
|
||
- Leaving a server reloads the page, so one server's mod code never stays loaded while playing on another.
|
||
|
||
## Security
|
||
|
||
**Client scripts.** Mod client code runs in the game page with the page's full access. How much that matters depends on
|
||
where the page came from:
|
||
|
||
- **Page served by the game server** (the default): that server already chose every line of JavaScript on the page, so
|
||
running its mods doesn't trust it any more than loading the page did.
|
||
- **Page from one origin, game server on another** (`?server=`): mod code from that server runs with _the page's_
|
||
origin, including its local storage. The client shows the server's mod list and asks before loading anything, and
|
||
remembers the answer per server and mod hash. The server needs CORS headers on `build/mods/`.
|
||
- The hash check confirms the files are the ones the server listed. It doesn't protect against a malicious server.
|
||
|
||
**Server scripts.** They run in a worker with **no Deno permissions** (Deno worker permissions; currently needs
|
||
`--unstable-worker-options`). They can't read files, open connections or run programs. Everything they need goes through
|
||
the mod API. Installing a server mod still means trusting it with the game world and everything players send.
|
||
|
||
**Players.** Clients are untrusted. The server checks reach and the item being held for every break, place and interact,
|
||
applies container rules on the server, validates form responses, and rate-limits mod channels. Server script code and
|
||
tile data are never sent to clients.
|
||
|
||
## Example mod
|
||
|
||
A smelter block with a container screen, a stats screen written in client code, and a channel between them.
|
||
|
||
`mods/copper_tools/manifest.json`
|
||
|
||
```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, biomes and trees | `common/generation.ts` | terrain generator `bworld:overworld` in `scripts/worldgen.ts` |
|
||
| Ore table | `common/generation.ts` | `worldgen/ores.json`, if it generates identically (see phase 3) |
|
||
| Texture credits (the Kenney packs) | `assets/ASSETS.md` | `CREDITS.md`, listed in the manifest's `credits` |
|
||
|
||
Not moved:
|
||
|
||
- **Crops** (`common/blocks/crops.ts`) are unfinished and mostly commented out. They stay where they are and get rebuilt
|
||
as a component once server scripts exist, as a good first test of tile data plus ticking.
|
||
- **The watering can's lore** (`get_lore`) isn't shown anywhere yet, because the game has no item tooltips. It moves
|
||
once tooltips exist.
|
||
|
||
### What stays in the engine
|
||
|
||
Rendering, physics and player controls; chunks, meshing and the generation passes (noise helpers, chunk assembly, ores,
|
||
features); networking, saving and the game server; registries and the mod loader; inventories, the crafting grid, slot
|
||
click rules and the generic container screen; chat and `/give`; `bworld:air`; and the engine assets in `assets/`: the UI
|
||
sprites, font, player sprite and the `engine:` textures (the breaking cracks and the missing texture).
|
||
|
||
### Rules that keep existing worlds working
|
||
|
||
Worlds saved before the move must load afterwards with nothing changed:
|
||
|
||
1. **Ids don't change.** Saves store blocks and items by string id, including player inventories and chest contents. The
|
||
JSON uses exactly the ids the TypeScript files register now.
|
||
2. **Terrain is identical, block for block.** A save only stores what players changed _on top of_ generated terrain. If
|
||
the generator's output changes at all, every existing world silently changes with it: trees move and blocks players
|
||
broke reappear. The golden terrain test (phase 0) enforces this.
|
||
3. **Tile data keeps its shape.** Chests store their items in `containers.main`, and furnaces keep `containers.main`
|
||
plus `progress`, `progress_max`, `fuel` and `fuel_max` in `data`. The components read exactly those, so tiles saved
|
||
by the current code load into them.
|
||
4. **The save format and protocol don't change.** Clients receive `mods/bworld` like any other mod.
|
||
|
||
### Phases
|
||
|
||
Each phase ends with every test passing and the game playable.
|
||
|
||
**Phase 0: safety net.** Do this first, before any other mod work. Started: `deno task test` runs `tests/`, which 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 `generate_chunk` to `mods/bworld/scripts/worldgen.ts` as the `bworld:overworld` terrain generator. It's a
|
||
straight port: the seeding rules in [Terrain generators](#terrain-generators) were chosen so the same noise names give
|
||
the same values.
|
||
- Keep trees inside the terrain generator rather than making them a feature. Right now a tree's leaves can be
|
||
overwritten by later columns of the same chunk; as a feature running after all terrain, they would win instead, and
|
||
the terrain would change.
|
||
- Try moving the ores to `ores.json` with `"replaces": "bworld:stone"`. The current code only places ores in stone, so
|
||
this should be identical, but only the golden test can say so. If it isn't, the ores stay inside the terrain
|
||
generator.
|
||
- Remove the content from `common/generation.ts`, leaving the passes and noise helpers.
|
||
- Check: `terrain.json` matches exactly, client and server terrain still agree, and `world_v2.json` loads unchanged.
|
||
|
||
**Phase 4: engine cleanup.**
|
||
|
||
- Replace the hardcoded water checks in `client/systems/player_controls.ts` and `server/game/game_server.ts` with the
|
||
`replaceable` block field.
|
||
- `/give` stops assuming `bworld:`. It looks names up across all namespaces and asks for the full id when two mods have
|
||
the same name.
|
||
- Rename engine asset keys like `bworld:ui` and `bworld:m6x11` in `client/main.ts` to `engine:`, so `bworld:` only means
|
||
content.
|
||
- The About page shows the engine's `assets/ASSETS.md` plus every loaded mod's credits.
|
||
- The server refuses to start without a terrain generator, naming the mods that could provide one.
|
||
|
||
### Done when
|
||
|
||
- Searching `client/`, `common/` and `server/` for `"bworld:` finds only `bworld:air`.
|
||
- Removing `mods/bworld` gives an engine that starts and says it needs a terrain generator, instead of crashing
|
||
somewhere.
|
||
- All tests pass, the golden fixtures are unchanged, and a world saved before the move plays exactly the same after it.
|
||
|
||
## Implementation plan
|
||
|
||
Steps 1–4 are done and 5, 6 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. _Partly done:_ `welcome`
|
||
lists the mods and clients download and run them before creating the world, but the server doesn't wait for `ready`
|
||
(its messages just queue up meanwhile), hashes aren't checked, and there's no confirm screen or CORS yet.
|
||
6. **Server scripts.** Components, events, commands, system, storage and `ctx.recipes`. Move `FURNACE_RECIPES` and
|
||
`FUEL_VALUES` into the recipe registry. _Mostly done_ (`server/game/mod_runtime.ts`). Not yet: `on_click` (clients
|
||
don't report left clicks on blocks), item `on_use` (there's no item use action), `world.get_state` / `set_state`
|
||
(block states aren't synced or saved), and `Container.on_change`. `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 game's only entity is the player, so mob mods are out of scope until the ECS has more entity types.
|
||
- **Hot reload.** Restarting the server and reconnecting is the version 1 answer.
|