diff --git a/MODS.md b/MODS.md index c4e8b9d..cbd94c5 100644 --- a/MODS.md +++ b/MODS.md @@ -1,51 +1,65 @@ # bworld mods specification -Status: **draft, format_version 1**. Nothing here is implemented yet, see [Implementation plan](#implementation-plan). +Status: **draft, format_version 1**, partly built. The [Implementation plan](#implementation-plan) lists what works +today and what's planned, and sections about planned features say so. -Mods add blocks, items, textures, recipes, world generation, game logic and GUIs to bworld. 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. +Mods add blocks, items, textures, recipes, world generation, game logic and GUIs to bworld, and can change how the game +itself works. A mod is installed **on the server**. Players who join download its client code, data and textures from +the server automatically, so they don't install anything themselves. bworld is always played on a server; there is no +single player mode. -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. +bworld's modding takes from three places: + +- **Minetest: the game is mods.** The engine is small, and everything that makes bworld _bworld_ (its blocks, items, + crafting, inventory screen, HUD, commands and terrain) lives in `mods/bworld`, written against the same API as any + other mod. Mods can change each other's content, including the base game's. +- **Minecraft Bedrock add-ons: easy to start.** Most content is JSON, blocks and items get behavior from named + components, scripts use before/after events and server-driven forms, and players get everything from the server. +- **Minecraft Java with Fabric: no ceiling.** A mod that needs to can reach into the engine itself and hook any of its + methods, on the server and on the client, like Fabric's mixins. ## Contents - [Overview](#overview) +- [Loading](#loading) - [Creating a mod](#creating-a-mod) - [Mod layout](#mod-layout) - [manifest.json](#manifestjson) - [Identifiers](#identifiers) -- [Textures](#textures) -- [Blocks](#blocks) -- [Block models](#block-models) -- [Items](#items) -- [Recipes](#recipes) -- [Server scripts](#server-scripts) -- [Client scripts](#client-scripts) -- [GUIs](#guis) -- [Mod channels](#mod-channels) -- [World generation](#world-generation) +- Data: [Textures](#textures), [Blocks](#blocks), [Block models](#block-models), [Items](#items), + [Recipes](#recipes), [Overrides](#overrides) +- Game API: [Common scripts](#common-scripts), [Server scripts](#server-scripts), [Client scripts](#client-scripts), + [GUIs](#guis), [Mod channels](#mod-channels), [World generation](#world-generation) +- [Engine access](#engine-access) - [Delivery to clients](#delivery-to-clients) - [Security](#security) - [Example mod](#example-mod) -- [The base game as a mod](#the-base-game-as-a-mod) +- [The engine and the base game](#the-engine-and-the-base-game) - [Implementation plan](#implementation-plan) - [Open questions](#open-questions) ## Overview -A mod can have up to three scripts, each running in a different place: +A mod is built from up to three layers. Most mods only need the first one or two. -| 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 | +| Layer | Like | What a mod writes | Stability | +| --------------------------------- | --------------------------------- | --------------------------------------------------------------------------------- | -------------------------------------------------- | +| **Data** | Bedrock behavior packs | JSON in `blocks/`, `items/`, `models/`, `recipes/`, `overrides/`, `worldgen/`, and textures | Versioned by `format_version` | +| **Game API** | Minetest's Lua API, Bedrock's Script API | TypeScript against `bworld/common`, `bworld/server`, `bworld/client` and `bworld/worldgen` | Stable, only breaks with the game's major version | +| **[Engine access](#engine-access)** | Fabric's mixins | `import { engine, hook } from "bworld/engine"`, opted into in the manifest | None, tied to one game version | -The mod's JSON data and textures go to both sides. +Data is shorthand for the Game API: loading `blocks/copper_block.json` does exactly what `ctx.blocks.register(...)` with +the same object does in a common script. So anything a JSON file can do, code can do too, for content that's easier to +generate (every color of wool) or that depends on other mods. + +A mod can have up to four scripts, each running in a different place: + +| Script | Runs on | Sent to clients | Owns | +| ---------- | ---------------------------------------------- | --------------- | ------------------------------------------------------------------------------------ | +| `common` | the server and every client, before the others | yes | content: registering blocks, items, models and recipes, and changing other mods' content | +| `server` | the server, in a worker | **never** | game logic: block components, ticking, tile data, containers, commands, events | +| `client` | every client, main thread | yes | presentation: custom screens, HUD, keybinds | +| `worldgen` | server and client chunk workers | yes | terrain and features, must be deterministic | **The server is the authority.** Clients send what the player is trying to do ("break the block at x, y, z", "click slot 3"). The server checks it, runs mod logic and sends back what happened. For responsiveness, clients show the expected @@ -55,17 +69,33 @@ result of breaking and placing immediately, and the server sends a correction if server process browser ┌───────────────────────────────────────┐ ┌──────────────────────────────────┐ │ host: http, websocket, files │ │ game client (rendering, input) │ -│ ┌───────────────────────────────────┐ │ ws │ + mod client scripts │ -│ │ game server worker │◄├──────────┤► (screens, HUD, keybinds) │ +│ ┌───────────────────────────────────┐ │ ws │ + mod common and client │ +│ │ game server worker │◄├──────────┤► scripts │ │ │ world, inventories, tile data │ │ protocol │ │ -│ │ + mod server scripts │ │ │ chunk workers │ +│ │ + mod common and 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. +## Loading + +The server and every client load mods the same way, in the same order: dependencies first, otherwise alphabetical. The +server sends its order to clients, and they follow it. + +1. **Content**, one mod at a time: the mod's JSON data is registered, then its `common` script's `setup` runs. So a + common script sees its own data and everything from the mods before it, and can change it. +2. **Freeze.** The registries close: blocks get their numeric ids, block items are created and recipes are indexed. + Registering or changing content after this throws. +3. **Scripts.** `server` scripts' `setup` runs on the server, `client` scripts' on each client, and `worldgen` scripts in + every chunk worker. These can read every registry but not change them. +4. The server starts ticking, or the client sends `ready` and joins. + +**Content has to come out the same on every side.** Common scripts run on the server and on every client, and all of +them must end up with exactly the same blocks, items and recipes: no `Math.random`, dates, or anything that differs +between the server and a client. After the freeze each side hashes its registries (every id and its definition), and +the client sends the hash with `ready`. When it doesn't match the server's, the player is turned away with a message +naming the first thing that differs, instead of playing with mismatched blocks. _Planned, phase 1._ ## Creating a mod @@ -74,14 +104,13 @@ deno task new-mod copper_tools "Copper Tools" # copies templates/mod to mods/c 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. +The template has one of everything: a block with a custom component, an item, a shaped recipe, textures, and a script +for each side. `check-mods` checks manifests and data files against this spec, checks that every id, item and texture a +mod refers to exists (and that it depends on the mods those come from), and typechecks scripts against the API types in +`common/mod_api/`, which scripts import as `bworld/common`, `bworld/server`, `bworld/client`, `bworld/worldgen` and, +with engine access, `bworld/engine`. Folders in `mods/` starting with `_` or `.` are ignored. -`deno task build` 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). +`deno task build` builds every mod in `mods/` and fails if any has errors; `deno task server` then loads them. ## Mod layout @@ -93,13 +122,15 @@ mods/ models/*.json items/*.json recipes/*.json + overrides/*.json # changes to other mods' content worldgen/ores.json textures/*.png scripts/ + common.ts # content, runs on both sides server.ts # game logic, stays on the server client.ts # sent to players worldgen.ts # sent to players, also runs on the server - shared/ # anything both sides import, bundled into each + shared/ # anything several scripts import, bundled into each ``` Only `manifest.json` is required. Data folders can have subfolders, with one definition per file. @@ -116,9 +147,11 @@ Only `manifest.json` is required. Data folders can have subfolders, with one def "authors": ["paula"], "game_version": ">=0.1.0", "dependencies": [ + { "id": "bworld", "version": "*" }, { "id": "more_ores", "version": "^2.0.0" } ], "scripts": { + "common": "scripts/common.ts", "server": "scripts/server.ts", "client": "scripts/client.ts", "worldgen": "scripts/worldgen.ts" @@ -134,16 +167,18 @@ Only `manifest.json` is required. Data folders can have subfolders, with one def | `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. | +| `game_version` | no | Semver range of bworld versions the mod works with. Mods with `engine_access` need one. | +| `dependencies` | no | Other mods by `id`, with a semver range. Missing or mismatched dependencies fail loading. A mod can only change content of mods it depends on. | +| `scripts.common` | no | Content entry, run on the server and sent to every player. _Planned, phase 1._ | | `scripts.server` | no | Server entry. Never sent to clients. | | `scripts.client` | no | Client entry, sent to every player. | | `scripts.worldgen` | no | Worldgen entry, sent to every player and also run on the server. | +| `engine_access` | no | `true` lets the mod's scripts import `bworld/engine`, see [Engine access](#engine-access). _Planned, phase 3._ | | `credits` | no | A markdown file in the mod, shown on the Credits screen. For asset licenses and thanks. | -Scripts can be `.js` or `.ts`. The build bundles each entry separately into one ES module. Code imported by 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. +Scripts can be `.js` or `.ts`. The build bundles each entry separately into one ES module. Code imported by several +entries is copied into each bundle, so **don't import secrets into shared code**. Anything the common, client or worldgen +bundles import is visible to players. ## Identifiers @@ -151,13 +186,13 @@ 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. + [The engine and the base game](#the-engine-and-the-base-game)). `engine` belongs to the engine itself, for things + every game needs, like the block-breaking cracks and the built-in block models. The one exception is `bworld:air`: + it's the engine's empty block, but it keeps that id because every save already contains it. - `name` must match `^[a-z0-9_]+$`. -- A mod only registers ids in its own namespace: blocks, items, components, screens, channels and HUD elements. It can - _refer to_ any id. -- Registering an id that already exists is a load error. +- A mod only registers ids in its own namespace: blocks, items, models, components, recipe types, screens, channels and + HUD elements. It can _refer to_ any id, and [change](#overrides) other mods' content. +- Registering an id that already exists is a load error. Changing an existing one is what overrides are for. Blocks get a numeric id when they register, and chunk data stores those numbers. Numeric ids are **local to one running program**, so the server and each client can number blocks differently. Saves and the network always use string ids. @@ -213,7 +248,7 @@ program**, so the server and each client can number blocks differently. Saves an | `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. | +| `components` | none | `components` | Custom components with parameters, handled by server scripts. | The client needs this data too (for meshing, mining time and collision), so it's sent to every player. It can't contain functions. @@ -348,7 +383,7 @@ clients as part of the container it's in, so client screens can show it. | `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` | +| `shaped` | `pattern` rows (space is empty), `key` letter to item id, `result` `{ id, count }` | Used by the crafting grid, fits anywhere in it | | `furnace` | `input` item id, `output` `{ id, count }`, `cook_time` in ticks | Used by the furnace | | `fuel` | `item` id, `burn_time` in ticks | Used by the furnace | | `smithing` | `tool` item id, `material` `{ id, count }`, optional `addition` item id, `result` item id | Used by the smithing table: upgrades the tool, keeping its data. Uses up the tool, `count` of the material and the addition | @@ -382,8 +417,128 @@ A shaped recipe, for the crafting grid in the player's inventory screen: ``` Two furnace recipes with the same `input`, two smithing recipes with the same tool, material and addition, or two -shaped recipes with the same pattern and key, are a load error. Server -scripts can also register recipe types of their own through `ctx.recipes`. +shaped recipes with the same pattern and key, are a load error. Mods can add recipe types of their own for their +machines, see [Common scripts](#common-scripts). + +## Overrides + +A mod can change content other mods registered, like Minetest's `override_item`. The JSON form covers the common cases, +and a [common script](#common-scripts) can do the same with `override` and `extend`. _Planned, phase 2._ + +`overrides/grass_weeds.json`: + +```json +{ + "format_version": 1, + "override": { + "block": "bworld:grass", + "set": { "mining": { "toughness": 1, "tool": "shovel" } }, + "add_components": { "farming_plus:weeds": { "chance": 0.01 } } + } +} +``` + +| Field | Meaning | +| ------------------- | ---------------------------------------------------------------------------------------------------- | +| `block` or `item` | The id to change. | +| `set` | Fields to replace, with the same names as in the block or item JSON. Each listed field is replaced whole, so `mining` above replaces all of the old `mining`. | +| `add_components` | Components to add. One the block or item already has gets these params instead. | +| `remove_components` | Component ids to take away. | + +A recipe override removes recipes, so a mod can replace them with its own: + +```json +{ + "format_version": 1, + "override": { "remove_recipes": { "type": "shaped", "result": "bworld:chest" } } +} +``` + +`remove_recipes` matches recipes whose fields equal the ones given (`result` matches the result's id). + +Rules: + +- A mod can only change content of mods it lists in `dependencies`, so it always loads after them. `check-mods` makes + this an error. +- Changes apply in load order, so when two mods change the same field the later one wins. `check-mods` warns about it. +- Ids never change, and blocks and items can't be removed, because saves refer to them. To make something unobtainable, + remove its recipes and drops. +- Overrides are content like everything else: they're applied on the server and every client, and are part of the + registry hash. + +## Common scripts + +`scripts.common` exports `setup`, called on the server and on every client while content loads, right after the mod's +own JSON is registered (see [Loading](#loading)). _Planned, phase 1._ + +```ts +import type { CommonContext } from "bworld/common"; + +const COLORS = ["white", "red", "green", "blue", "black"]; + +export function setup(ctx: CommonContext) { + for (const color of COLORS) { + ctx.blocks.register({ + id: `wool:${color}_wool`, + textures: `wool:${color}_wool`, + mining: { toughness: 1 }, + drops: `wool:${color}_wool`, + }); + ctx.recipes.register({ + type: "shaped", + pattern: ["D", "W"], + key: { D: `wool:${color}_dye`, W: "wool:white_wool" }, + result: { id: `wool:${color}_wool`, count: 1 }, + }); + } + + // wool burns in the base game's furnace + for (const color of COLORS) { + ctx.recipes.register({ type: "fuel", item: `wool:${color}_wool`, burn_time: 100 }); + } + + // every leaf block any mod registered drops sticks sometimes + for (const id of ctx.blocks.ids().filter((id) => id.endsWith("_leaves"))) { + ctx.blocks.extend(id, { components: { "wool:stick_drop": { chance: 0.05 } } }); + } +} +``` + +```ts +interface CommonContext { + mod: { id: string; version: string }; + blocks: ContentRegistry; + items: ContentRegistry; + models: ContentRegistry; + recipes: RecipeRegistry; + log(...args: unknown[]): void; +} + +// T is the same shape as the JSON file's content, checked the same way +interface ContentRegistry { + register(definition: T): void; + get(id: string): Readonly | undefined; // what's registered so far, by any mod + ids(): string[]; + override(id: string, fields: Partial): void; // like "set" in an override file + extend(id: string, change: { components?: Record; remove_components?: string[] }): void; +} + +interface RecipeRegistry { + register(recipe: RecipeJson): void; + remove(match: (recipe: RecipeJson) => boolean): number; // how many it removed + list(type: string): readonly RecipeJson[]; + // a new recipe type: its recipes are checked with validate, and scripts read them with list + register_type(id: string, type: { validate(recipe: unknown): string[] }): void; +} +``` + +- Definitions are checked exactly like JSON files, and a problem is a load error naming the mod. +- Common scripts only run during loading. They can't keep state for later, and can't reach the world, players or + anything else: that's what server and client scripts are for. They get the same `ctx` shape on both sides on + purpose, so they can't accidentally register different things. +- Recipe types are ids like everything else. `shaped`, `furnace`, `fuel` and `smithing` are short for `bworld:shaped` + and so on, since the base game's crafting grid, furnace and smithing table use them. A mod's machine registers its own + type (`copper_tools:alloy`) and its server script reads the recipes with `ctx.recipes.list("copper_tools:alloy")`. ## Server scripts @@ -400,6 +555,9 @@ export function setup(ctx: ServerContext) { Server scripts run in the game server worker with **no Deno permissions** (see [Security](#security)). They get the mod API and standard JavaScript, but no file, network or subprocess access; persistent state goes through `ctx.storage`. +`ctx` is the Game API for the server. Content is already registered and frozen by now (see [Loading](#loading)), so +server scripts read registries but don't change them. + ```ts interface ServerContext { mod: { id: string; version: string }; @@ -557,7 +715,7 @@ ctx.commands.register("heal", { ``` Commands run on the server when a player types `/name` in chat. Two mods using the same name, or a mod using one of the -engine's (`give`, `time` and `tps`), is a load error. `/copper_tools:heal` always works as the unambiguous form. +engine's (`give`, `time` and `tps`, until phase 5 moves `give` and `time` into `mods/bworld`), is a load error. `/copper_tools:heal` always works as the unambiguous form. ## Client scripts @@ -584,6 +742,8 @@ interface ClientContext { } ``` +Only `ctx.player`, `ctx.world` and `ctx.log` work so far, the rest is phase 4. + Client scripts are for presentation. They can't change the world or inventories; they ask the server over a [mod channel](#mod-channels). Anything they show should be treated as a view of the server's state. Everything in a client script is visible to players and can be changed by them, so it must not be trusted for anything that matters. @@ -597,6 +757,8 @@ There are three ways to show a GUI. They're listed from least code to most. Pick ### 1. Forms (server only) +_Planned, phase 4._ + Simple dialogs defined entirely by server code, like Bedrock's `@minecraft/server-ui`. The client draws them with the game's UI style. No client script is needed. @@ -716,6 +878,8 @@ Items handed to scripts are live: setting `count` changes the stack, and a stack ### 3. Custom screens (client code) +_Planned, phase 4._ + For anything forms and containers can't do: maps, skill trees, minigames, custom layouts. The client script registers a screen class, and the server opens it with some props: @@ -798,6 +962,8 @@ doesn't involve the server. ### HUD +_Planned, phase 4._ + ```ts ctx.hud.register("copper_tools:heat", { on_render(g) { @@ -810,7 +976,7 @@ HUD elements draw every frame after the world, under open screens. They get thei ## Mod channels -Client and server parts of a mod talk over named channels. Messages are JSON. +_Planned, phase 4._ Client and server parts of a mod talk over named channels. Messages are JSON. ```ts // server.ts @@ -848,7 +1014,7 @@ Each chunk is generated in three passes: 3. **Features.** Every mod's registered features, in load order. The world is 256 blocks tall and the sea is at y 64 (`CHUNK_HEIGHT` and `SEA_LEVEL` in `common/constants.ts`). The -base overworld lives in `common/worldgen/` until phase 3 moves it into `mods/bworld`. It works like Minecraft 1.18+ and +base overworld lives in `common/worldgen/` until phase 6 moves it into `mods/bworld`. It works like Minecraft 1.18+ and the Terralith datapack: continentalness, erosion and weirdness noises feed nested splines that give every column a height, jaggedness and roughness, a 3D density around that height is sampled on a coarse grid and interpolated, and caves are cut out of it. On top of that come Terralith-style shapes: terraced plateaus, shattered hills, river valleys @@ -859,7 +1025,8 @@ how many grow and which kinds. `deno run -A tools/worldgen_preview.ts [seed]` re ### Terrain generators -A world uses exactly one terrain generator, registered by a worldgen script. The base game's is `bworld:overworld`. +A world uses exactly one terrain generator, registered by a worldgen script. The base game's will be `bworld:overworld` +(phase 6); until then `register_terrain` throws and the engine's own overworld is used. Which one a world uses is saved with the world. A new world uses the only one installed, or the server's `TERRAIN` setting when there are several. A server with no terrain generator refuses to start. @@ -954,6 +1121,111 @@ interface FeatureChunk { - Each feature's `rng` is seeded with its own id, so adding or removing another mod's feature doesn't change what this one generates. +## Engine access + +For what the Game API doesn't cover yet: a new kind of rendering, different player physics, a change to how lighting +spreads, an entity type. Like Fabric's mixins, a mod can reach the engine's own classes and hook their methods, on the +server and on the client. It's powerful and unstable at once, so a mod has to ask for it. _Planned, phase 3._ + +```json +{ "engine_access": true, "game_version": "~0.4.0" } +``` + +```ts +// client.ts: an outline around every dropped item +import type { ClientContext } from "bworld/client"; +import { engine, hook } from "bworld/engine"; + +export function setup(ctx: ClientContext) { + hook(engine.client.LevelRenderer.prototype, "render_entities", { + after(renderer, [level, camera_entity, partial_tick]) { + for (const entity of level.entities.values()) { + if (entity instanceof engine.client.ItemEntity) draw_outline(entity, partial_tick); + } + }, + }); +} +``` + +```ts +// server.ts: broken blocks drop twice +import { engine, hook } from "bworld/engine"; + +export function setup() { + hook(engine.server.GameServer.prototype, "pop_item", { + after(game, [x, y, z, stack], entity) { + game.spawn_item(entity.x, entity.y, entity.z, stack.clone()); + return entity; + }, + }); +} +``` + +```ts +// client.ts: everything falls at half speed. physics is an object so its functions can be hooked, see below +import { engine, hook } from "bworld/engine"; + +export function setup() { + hook(engine.common.physics, "move_body", { + before(_physics, args) { + args[1] /= 2; // gravity + }, + }); +} +``` + +### What `bworld/engine` gives + +- `engine` holds the engine's modules as they run on that side: `engine.common` (registries, constants, inventory, + physics, generation, the protocol types) everywhere, `engine.server` (`GameServer`, `ServerWorld`, `ModRuntime`, the + running `game`) in server scripts, and `engine.client` (`Client`, `ClientLevel`, the renderer, GUI classes, the running + `client`) in client scripts. Worldgen scripts get `engine.common` only. +- They're the live classes and objects the game uses, not copies: the engine puts them on the global object at startup, + and `bworld/engine` in a mod's bundle is a small shim that reads them. So a hook on a prototype changes every instance + the engine already made. +- The types are the engine's own source, so `check-mods` typechecks a mod against exactly the engine it will run on, and + an engine change that breaks a mod shows up there first. + +### Hooks + +```ts +function hook(target: T, method: K, handlers: { + // runs first. change args in place, or return { result } to skip the method and everything after + before?(self: T, args: Parameters): { result: ReturnType } | void; + // wraps the method, calling original (or not) + replace?(self: T, args: Parameters, original: T[K]): ReturnType; + // runs last, and returns the result the caller gets + after?(self: T, args: Parameters, result: ReturnType): ReturnType; + // lower runs earlier, the default is 1000. ties go in load order + priority?: number; +}): () => void; // unhook +``` + +- Hooks on a prototype apply to every instance, hooks on one object only to it. +- Several mods can hook the same method. `before` hooks run in priority order, `replace` hooks nest (the first one's + `original` is the next one's wrapper, the last one's is the engine's method), and `after` hooks run in priority order, + each getting the previous one's result. +- A hook that throws is logged under its mod and skipped, like event handlers. The method still runs. +- Functions exported from a module can't be hooked, because a module's exports can't be reassigned. The engine puts + functions mods may want to change on plain objects (like `engine.common.physics`) and calls them through those objects + itself, so a hook changes what the engine calls. Classes' methods are hookable on their prototype. +- JavaScript's `#private` members can't be hooked or read. The engine keeps things that mods should be able to change + as normal members, and `#private` for things with rules that must hold (like a container's slots). Making something + hookable is an ordinary engine change, and asking for one is how the Game API grows. +- Hooks reach one JavaScript realm: the game server worker for server scripts, the page for client scripts, and one + chunk worker for worldgen scripts. Each chunk worker runs its own copy of the engine, so a hook in a client script + doesn't change meshing; a worldgen script with engine access can. + +### Rules + +- **It isn't a stable API.** Any game update can rename or change what a hook targets. A mod with `engine_access` must + set `game_version` to a range within one minor version (like `~0.4.0`). Loading it on another version is a load error + naming the mod, instead of a crash somewhere later. +- **Prefer the Game API.** When it can do something, it keeps working across updates. Engine access is for what it + can't do yet, and a mod that hooks the same thing as many others is a sign it should become part of the Game API. +- Without `engine_access`, importing `bworld/engine` is a `check-mods` error and a load error. +- See [Security](#security) for what it means for players and servers. + ## Delivery to clients ### Build @@ -963,7 +1235,8 @@ interface FeatureChunk { ``` build/mods/// # public, served to players manifest.json - data.json # all blocks, items, recipes and ores merged + data.json # all blocks, items, models, recipes, overrides and ores merged + common.js client.js worldgen.js server_mods/// # private, outside the static root, never served @@ -986,11 +1259,11 @@ client server │ │ │ [cross-origin: confirm screen] │ │ download the atlas and every mod file, │ - │ check each one's sha256, register data │ + │ check each one's sha256, load content │ │ in the listed order, run client │ │ setup(), start chunk workers │ │ │ - │ ready │ + │ ready { registry_hash } │ a different hash gets rejected { reason } ├────────────────────────────────────────►│ │ join { id, name, players, entities, │ the player is created here, player_join fires, │ changes, spawn, selected_slot } │ and their inventory follows as container messages @@ -1024,6 +1297,12 @@ where the page came from: headers on `build/mods/`. - The hash check confirms the files are the ones the server listed. It doesn't protect against a malicious server. +**Engine access.** A server mod with engine access controls the game server worker completely, but the worker still +has no Deno permissions, so it can't reach files or the network any more than other server scripts. A client mod with +engine access changes nothing about what it _can_ reach, since client scripts already run with the page's full access, +but it can change how the game behaves for players in ways the Game API can't. The confirm screen for other servers +lists which mods use engine access. + **Server scripts.** They run in a worker with **no Deno permissions** (Deno worker permissions; currently needs `--unstable-worker-options`). They can't read files, open connections or run programs. Everything they need goes through the mod API. Installing a server mod still means trusting it with the game world and everything players send. @@ -1044,6 +1323,7 @@ A smelter block with a container screen, a stats screen written in client code, "id": "copper_tools", "name": "Copper Tools", "version": "1.0.0", + "dependencies": [{ "id": "bworld", "version": "*" }], "scripts": { "server": "scripts/server.ts", "client": "scripts/client.ts" } } ``` @@ -1152,182 +1432,150 @@ class SmelterStats implements ModScreen { } ``` -## The base game as a mod +## The engine and the base game -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. +Like a Minetest game, bworld is the engine plus one mod, `mods/bworld`, written against the same API as any other mod. +This is how the API gets tested for real: if the base game can't be written as a mod, the API is missing something, and +other mods would hit the same wall. It also means there's one way content works, so mods can do anything the base game +does, and change any of it. -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. +**The engine** keeps what every game built on it needs: rendering, meshing, lighting and the sky; physics, player +movement and input; networking, saving, the game server and its loop; registries, the mod loader and the Game API; +containers, slot click rules, screens and the GUI toolkit; item entities; chat; world time; `/tps`; `bworld:air`; and +the engine assets in `assets/` (UI sprites, font, player sprite, and the `engine:` textures and block models). -### What moves +**`mods/bworld`** has everything else: -| Content | Now | In `mods/bworld` | -| ------------------------------------ | ------------------------------ | --------------------------------------------------------------- | -| 18 blocks | `common/blocks/*.ts` | `blocks/*.json` | -| 11 items | `common/items/*.ts` | `items/*.json` | -| 62 textures | `assets/sprites/textures/` | `textures/` | -| 5 crafting recipes | `server/game/crafting.ts` | `recipes/*.json`, type `shaped` | -| 6 furnace recipes, 2 fuels | `server/game/blocks.ts` | `recipes/*.json`, types `furnace` and `fuel` | -| Hoeing grass and dirt | `server/game/blocks.ts` | component `bworld:hoeable`, params `{ tool, into }` | -| Chest | `server/game/blocks.ts` | component `bworld:storage`, params `{ rows }` | -| Furnace (smelting, fuel, its screen) | `server/game/blocks.ts` | component `bworld:furnace` | -| Watering can's starting water | `common/items/watering_can.ts` | item component `bworld:watering_can`, params `{ max_water }` | -| Terrain and biomes | `common/worldgen/` | terrain generator `bworld:overworld` in `scripts/worldgen.ts` | -| Ore table (`BASE_ORES`) | `common/generation.ts` | `worldgen/ores.json`, unchanged | -| Texture credits (the Kenney packs) | `assets/ASSETS.md` | `CREDITS.md`, listed in the manifest's `credits` | - -Not moved: - -- **Carrots, potatoes, tomatoes and pumpkins** (`common/blocks/crops.ts`) are unfinished and mostly commented out. - Wheat uses the `bworld:crop` component instead, and they can too once they have blocks. -- **The watering can's lore** moved into its component's `get_lore`, but it isn't shown anywhere yet, because the game - has no item tooltips. - -### 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). +| What | Where it is now | Status | +| ---------------------------------------------------- | ----------------------------------- | ----------------------- | +| Blocks, items, models, recipes, textures, credits | `mods/bworld` JSON and `textures/` | Done | +| Hoeing, chest, furnace, smithing table, crops, watering can | `mods/bworld/scripts/server.ts` | Done | +| Recipe types `shaped`, `furnace`, `fuel`, `smithing` | engine (`common/mod_loader.ts`, `server/game/crafting.ts`) | Phase 5 | +| The player's inventory screen and crafting grid | engine (`client/gui/gui_player_inventory.ts`, `server/game/crafting.ts`) | Phase 5 | +| The hotbar and crosshair | engine (`client/gui/hud.ts`) | Phase 5 | +| `/give` and `/time` | engine (`server/game/game_server.ts`) | Phase 5 | +| Mining: tool speed, `requires_tool`, drops | engine (`client/game_mode.ts`, `server/game/game_server.ts`) | Phase 5 | +| Day and night lengths, sky colors | engine (`common/time.ts`, `client/rendering/game_renderer.ts`) | Phase 5 | +| Where new players spawn | engine (`server/game/world.ts`) | Phase 5 | +| Terrain, biomes, trees and ores | engine (`common/worldgen/`, `common/generation.ts`) | Phase 6 | ### Rules that keep existing worlds working -Worlds saved before the move must load afterwards with nothing changed: +Worlds saved before any of this must load afterwards with nothing changed: -1. **Ids don't change.** Saves store blocks and items by string id, including player inventories and chest contents. The - JSON uses exactly the ids the TypeScript files register now. +1. **Ids don't change.** Saves store blocks and items by string id, including player inventories and chest contents. 2. **Terrain is identical, block for block.** A save only stores what players changed _on top of_ generated terrain. If the generator's output changes at all, every existing world silently changes with it: trees move and blocks players - broke reappear. The golden terrain test (phase 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 protocol doesn't change, and old saves load.** Clients receive `mods/bworld` like any other mod. The save - format may change when it has to, as long as older saves are upgraded when loading (phase 2 did, to version 3). + broke reappear. The golden terrain test (phase 6) enforces this. +3. **Old saves are upgraded when loading.** The save format can change when it has to, like version 3 did for + containers, as long as older saves load into the new shape. `tests/fixtures/` keeps a save from each version. +4. **The protocol can change, with `PROTOCOL_VERSION`.** Clients on another version are asked to reload before anything + else happens. -### Phases +## Implementation plan -Each phase ends with every test passing and the game playable. +**Done so far:** the mod loader, build and delivery to clients (hashed downloads, the confirm screen for other servers, +credits); the data layer (blocks, block models with variants, items, recipes, ores); server scripts with components, +events, commands, timers, storage, recipes, containers and container screens; worldgen features and ores; the base +game's block behavior as a mod; world time with day and night. The code is in `common/mod_loader.ts`, +`common/mod_data.ts`, `server/game/mod_runtime.ts`, `client/mods.ts` and `common/worldgen_loader.ts`. -**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. +Each phase ends with every test passing and the game playable. Phases 1 to 3 are the foundation; 4 to 6 can happen in +any order after them. -- 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: loading and common scripts.** -**Phase 1: data** (after steps 4 and 5). _Done._ +- `scripts.common`, bundled like the client script, run on the server and on clients between each mod's data and the + next mod. +- `CommonContext` with `ctx.blocks`, `ctx.items`, `ctx.models` and `ctx.recipes` (`register`, `get`, `ids`). The JSON + loader goes through the same functions, so there's one path for content. +- The freeze: registries close after content loads, and registering later throws. Numeric ids, block items and recipe + indexes are made at the freeze, not while registering. +- The registry hash: both sides hash their registries after the freeze, the client sends its hash with `ready`, and a + mismatch turns the player away naming the first difference. +- Recipe types become ids with `register_type` and `list`, with `shaped`, `furnace`, `fuel` and `smithing` as short + names for the base game's. Server scripts get `ctx.recipes.list(type)`. +- Check: a test mod registers blocks, items and recipes from code, and the server and a client end up with equal + registries and hashes. A common script that registers something different on each side is caught by the hash. -- 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: overrides.** -**Phase 2: behavior** (after step 6, and container screens from step 7). _Done._ +- `override` and `extend` in common scripts, and `overrides/*.json` for the same thing as data. `recipes.remove`. +- `check-mods`: changing content of a mod that isn't a dependency is an error, two mods changing the same field is a + warning. +- Check: a test mod adds a component to `bworld:grass`, changes `bworld:glass`'s toughness and replaces the chest + recipe, and all of it works on both sides and in the registry hash. -- `mods/bworld/scripts/server.ts` registers `bworld:hoeable`, `bworld:storage`, `bworld:furnace`, `bworld:crop` (bone - meal on wheat) and `bworld:watering_can`. The chest and furnace open their screens with `ctx.ui.open_container`, with - the same layouts as before. -- The block and item JSON lists the components. `server/game/blocks.ts` is deleted. -- Saves went to version 3: tiles only keep `mod_data`, and containers are saved by id. Version 2 chests and furnaces are - upgraded when loaded: their `data` fields stay and their `containers.main` becomes a container whose id is in - `container`, which is the shape the components use. -- Check: `tests/base_game_test.ts` (hoeing, chest, furnace smelting, breaking a chest gives its contents back, bone meal, - the watering can) and `tests/fixtures/world_v2.json`, saved by the engine code before the move, pass. +**Phase 3: engine access.** -**Phase 3: world generation** (after step 9). +- The engine puts its modules on the global object at startup on each side (page, game server worker, chunk workers), + and the build turns `bworld/engine` imports into a shim that reads them. Types come from the engine's source. +- `hook` with `before`, `replace`, `after`, priorities and unhooking. +- `engine_access` in the manifest: `check-mods` and the loader refuse `bworld/engine` imports without it, and refuse + loading on a game version outside the mod's range. The confirm screen and the join screen say which mods use it. +- Go through the engine for things mods will want to change and make them hookable: move module functions like + `move_body` onto objects the engine calls through, and turn `#private` members that don't protect a rule into normal + ones. +- Check: test mods hook a server method, a client method and a worldgen method, several hooks on one method run in + priority order, and a throwing hook is logged without breaking the game. -- Port `common/worldgen/` to `mods/bworld/scripts/worldgen.ts` as the `bworld:overworld` terrain generator. It's a - straight port: it only uses named noises, which are exactly `noise_2d` / `noise_3d` in - [Terrain generators](#terrain-generators). -- Move `BASE_ORES` to `mods/bworld/worldgen/ores.json`. They already run through the same ore pass as mods' ores, in - the same order, so the result is identical. -- Remove the content from `common/generation.ts`, leaving the passes and noise helpers. -- Check: `terrain.json` matches exactly, client and server terrain still agree, and `world_v2.json` loads unchanged. +**Phase 4: the rest of the Game API.** -**Phase 4: engine cleanup.** +- Mod channels (`ctx.net` on both sides, `{ type: "mod", channel, data }`, the size and rate limits). +- Keybinds (`ctx.input.bind`), forms (`message_form`, `action_form`, `modal_form`), custom screens with `Graphics`, and + HUD elements. +- More events: items picked up and dropped, containers clicked, players moving between chunks, entities. +- Check: the example mod below works end to end, and every server event has a test. -- Replace the hardcoded water checks in `server/game/game_server.ts` with the `replaceable` block field, like - `client/game_mode.ts` already does. -- `/give` stops assuming `bworld:`. It looks names up across all namespaces and asks for the full id when two mods have - the same name. -- Rename engine asset keys like `bworld:ui` and `bworld:m6x11` in `client/main.ts` to `engine:`, so `bworld:` only means - content. -- The Credits screen (title screen and pause menu) shows the engine's `assets/ASSETS.md` plus every loaded mod's credits. - _Done._ Credits are published next to the mod's data and checked by hash like everything else. +**Phase 5: gameplay out of the engine.** + +- `/give` and `/time` become commands in `mods/bworld`. `/tps` stays, it's about the engine. +- The crafting grid's matching and the `shaped` recipe type move into `mods/bworld`, and the player's inventory screen + becomes a screen `mods/bworld` registers, opened by the engine's inventory key. +- The hotbar and crosshair become `mods/bworld` HUD elements. +- Mining rules (how long breaking takes, tool speed, `requires_tool`, what drops) become something `mods/bworld` sets, + with the engine asking it on both sides so clients still predict breaking. +- The day and night lengths and sky colors become settings `mods/bworld` makes in its common script. The engine keeps + counting ticks. +- Where new players spawn becomes `mods/bworld`'s choice. +- Check: all tests pass unchanged, and `world_v2.json` and a version 3 save still load. + +**Phase 6: world generation.** + +- First the golden fixture: `tests/fixtures/terrain.json`, a SHA-256 of the final blocks of 64 chunks for 3 seeds, + including negative coordinates and chunks with trees on their borders, recorded from the current code. +- Port `common/worldgen/` to `mods/bworld/scripts/worldgen.ts` as the `bworld:overworld` terrain generator. It only uses + named noises, which are exactly `noise_2d` / `noise_3d` in [Terrain generators](#terrain-generators). +- Move the ore table to `mods/bworld/worldgen/ores.json`, and take the content out of `common/generation.ts`, leaving + the passes and noise helpers. - The server refuses to start without a terrain generator, naming the mods that could provide one. +- Check: `terrain.json` matches exactly, client and server terrain still agree, and old saves load unchanged. -### Done when +**Done when:** - Searching `client/`, `common/` and `server/` for `"bworld:` finds only `bworld:air`. - Removing `mods/bworld` gives an engine that starts and says it needs a terrain generator, instead of crashing somewhere. -- All tests pass, the golden fixtures are unchanged, and a world saved before the move plays exactly the same after it. - -## Implementation plan - -Steps 1–6 are done and 9 mostly, enough that a mod made from the template loads and runs. Each step keeps the game -working: - -1. **Game server core.** Move `client/generation.ts` to `common/` (it already only needs constants and the rng package) - and have the server generate terrain. Move world state, chunks, tile data and player inventories into a game server - module that runs in a Deno worker, with `server/main.ts` handling HTTP, WebSockets and files. -2. **Server authority.** Change the protocol from "here's the block I changed" to intents (`break_block`, `place_block`, - `interact`, `select_slot`, `container_click`). The client keeps showing breaks and places immediately and accepts - corrections. Move inventories, drops and `/give` to the server. Replace `GuiChest` / `GuiFurnace` with server-synced - containers. -3. **Server only.** Remove the offline fallback in `client/main.ts`, which currently starts a local game when it can't - reach a server, and show a connection error instead. -4. **Mod loader and build.** Discover mods, check manifests, sort by dependencies, turn JSON into registry entries, - build the combined atlas (textures are currently all named `bworld:`), bundle scripts per side, and write - hashed output to `build/mods/` and `server_mods/`. _Done._ -5. **Delivery.** Split `welcome` into `welcome` / `ready` / `join`. Clients download, verify and run mods before - joining. Add the confirm screen for cross-origin servers and CORS headers on the server. _Done._ -6. **Server scripts.** Components, events, commands, system, storage and `ctx.recipes`. Move `FURNACE_RECIPES` and - `FUEL_VALUES` into the recipe registry. _Done_ (`server/game/mod_runtime.ts`, with the loop in - `server/game/game_loop.ts`). `ctx.net` throws until step 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. _Container screens done_ (`ctx.containers`, - `ctx.ui.open_container`), forms and custom screens throw until they're built. -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. +- The base game uses only the Game API, no engine access. If it needs a hook for something, that thing belongs in the + Game API. +- All tests pass, the golden fixtures are unchanged, and a world saved before any of this plays exactly the same. ## Open questions - **Chunk delivery.** Clients generate terrain from the seed, which needs deterministic worldgen and trusts clients to run the same generator. Sending chunks from the server instead (like Minecraft) removes both problems but costs bandwidth, roughly 5–15 KB per chunk compressed. It could be worth it once the server generates terrain anyway. -- **Client-only mods.** Should players be able to install their own client mods (minimaps, UI tweaks) 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-only mods.** Should players be able to install their own client mods (minimaps, UI tweaks, shaders) that work + on any server, like Fabric's client mods? They'd need a separate `"side": "client"` kind of mod that can't add content + or change the registry hash, and servers might want to forbid them. - **Client-side prediction for mods.** Custom block components only run on the server, so interacting with a mod block - always waits one round trip. 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. + always waits one round trip. Components could get optional handlers in common scripts that clients run to predict. +- **Engine access versions.** Tying engine access mods to one minor version is simple but strict. Fabric gets further + with mapped names and mixin error reporting; how much of that is worth building depends on how often the engine + changes under mods. +- **Game API versions.** The Game API only breaking with the game's major version is a promise that needs deprecation + warnings and a changelog to keep. - **Screen scaling.** The GUI currently works in raw canvas pixels (`SLOT_SIZE` is 54). `Graphics` should probably use a UI scale the player can change, with screens laid out in scaled units. -- **Entities.** The only entities are players (`client/entity/`), so mob mods are out of scope until there are more - entity types. +- **Entities.** The only entities are players and dropped items, so mob mods need entity types in the Game API first. - **Hot reload.** Restarting the server and reconnecting is the version 1 answer.