Mods plan
This commit is contained in:
@@ -0,0 +1,944 @@
|
||||
# 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. Single player works the same way, with a server running inside the browser.
|
||||
|
||||
This borrows from Minecraft Bedrock add-ons: data lives in JSON, blocks get behavior from named custom components, and
|
||||
scripts use before/after events. Bedrock servers also push resource packs to joining players, and their server scripts
|
||||
can show forms that the client draws (`@minecraft/server-ui`). bworld goes one step further and pushes client _scripts_
|
||||
too, since the client is already a web page.
|
||||
|
||||
## Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [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)
|
||||
- [Single player](#single-player)
|
||||
- [Security](#security)
|
||||
- [Example mod](#example-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.
|
||||
|
||||
## 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. |
|
||||
|
||||
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_]+$`, be at most 32 characters, and not be `bworld`, which is
|
||||
the base game.
|
||||
- `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 and logs a warning. It isn't a load error.
|
||||
|
||||
## 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. |
|
||||
| `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 |
|
||||
| --------- | --------------------------------------------------------------- | ----------------------------------------------- |
|
||||
| `furnace` | `input` item id, `output` `{ id, count }`, `cook_time` in ticks | Same as the table in `client/blocks/furnace.ts` |
|
||||
| `fuel` | `item` id, `burn_time` in ticks | Same as `FUEL_VALUES` in `furnace.ts` |
|
||||
|
||||
Two recipes with the same `input` are a load error. Server scripts can also register recipe types of their own through
|
||||
`ctx.recipes`. Crafting recipes are reserved until the game has crafting.
|
||||
|
||||
## Server scripts
|
||||
|
||||
`scripts.server` exports `setup`, called once when the server starts:
|
||||
|
||||
```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. They get the mod API and standard JavaScript, and **no Deno, Node or DOM
|
||||
APIs**. That lets the same script run on a dedicated server and in the browser for [single player](#single-player). They
|
||||
have no file or network 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;
|
||||
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**.
|
||||
|
||||
`worldgen/ores.json` uses the same fields as the `ORES` table in `client/generation.ts`:
|
||||
|
||||
```json
|
||||
{
|
||||
"format_version": 1,
|
||||
"ores": [
|
||||
{ "id": "copper_tools:rich_copper_ore", "min_y": 5, "max_y": 40, "scale": 0.05, "threshold": 0.72 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`scripts.worldgen` registers features, which run on each chunk after the base terrain and trees:
|
||||
|
||||
```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.
|
||||
|
||||
## Single player
|
||||
|
||||
Single player runs the same game server inside the browser, like Minecraft's integrated server:
|
||||
|
||||
- The game server core (world, inventories, tile data, mod server scripts) is written against a small host interface for
|
||||
**transport** and **storage**, with no Deno or DOM APIs.
|
||||
- On a dedicated server, the host is `server/main.ts`: WebSocket transport, files for storage. The core runs in a Deno
|
||||
worker.
|
||||
- In single player, the host is the page: `postMessage` transport, IndexedDB for storage. The core runs in a web worker,
|
||||
and `server.js` bundles are loaded from `server_mods/`, which the local build serves only to this page.
|
||||
|
||||
The client uses the same protocol either way, so a mod written and tested in single player behaves the same on a
|
||||
dedicated server.
|
||||
|
||||
## Security
|
||||
|
||||
**Client scripts.** Mod client code runs in the game page with the page's full access. How much that matters depends on
|
||||
where the page came from:
|
||||
|
||||
- **Page served by the game server** (the default): that server already chose every line of JavaScript on the page, so
|
||||
running its mods doesn't trust it any more than loading the page did.
|
||||
- **Page from one origin, game server on another** (`?server=`): mod code from that server runs with _the page's_
|
||||
origin, including its local storage. The client shows the server's mod list and asks before loading anything, and
|
||||
remembers the answer per server and mod hash. The server needs CORS headers 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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation plan
|
||||
|
||||
None of this exists yet. 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. Pull world state, chunks, tile data and player inventories into an
|
||||
environment-free module behind a transport and storage interface. Run it in a Deno worker from `server/main.ts`.
|
||||
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. **Integrated server.** Run the same core in a web worker for single player, with IndexedDB storage.
|
||||
4. **Mod loader and build.** Discover mods, check manifests, sort by dependencies, turn JSON into registry entries,
|
||||
build the combined atlas (textures are currently all named `bworld:<file>`), bundle scripts per side, and write
|
||||
hashed output to `build/mods/` and `server_mods/`.
|
||||
5. **Delivery.** Split `welcome` into `welcome` / `ready` / `join`. Clients download, verify and run mods before
|
||||
joining. Add the confirm screen for cross-origin servers and CORS headers on the server.
|
||||
6. **Server scripts.** Components, events, commands, system, storage and `ctx.recipes`. Move `FURNACE_RECIPES` and
|
||||
`FUEL_VALUES` into the recipe registry.
|
||||
7. **GUIs.** Forms, then container screens (rebuild chest and furnace with them), then custom screens, the `Graphics`
|
||||
API (built on the existing renderer and debug UI widgets) and the HUD.
|
||||
8. **Mod channels** and keybinds.
|
||||
9. **Worldgen mods.** Worldgen URLs and mod ores go into the chunk worker `init` message. Workers must finish importing
|
||||
before generating anything.
|
||||
|
||||
Steps 1–3 are engine work every multiplayer feature needs, with or without mods. Mods could start with data only (step 4
|
||||
plus data in step 5) before scripts exist.
|
||||
|
||||
## 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.
|
||||
Reference in New Issue
Block a user