This commit is contained in:
2026-09-24 23:28:01 -03:00
parent 79faa556de
commit bb42dd662e
73 changed files with 2349 additions and 34 deletions
+251 -26
View File
@@ -14,6 +14,7 @@ too, since the client is already a web page.
## Contents
- [Overview](#overview)
- [Creating a mod](#creating-a-mod)
- [Mod layout](#mod-layout)
- [manifest.json](#manifestjson)
- [Identifiers](#identifiers)
@@ -29,6 +30,7 @@ too, since the client is already a web page.
- [Delivery to clients](#delivery-to-clients)
- [Security](#security)
- [Example mod](#example-mod)
- [The base game as a mod](#the-base-game-as-a-mod)
- [Implementation plan](#implementation-plan)
- [Open questions](#open-questions)
@@ -64,6 +66,21 @@ result of breaking and placing immediately, and the server sends a correction if
Moving from the current setup to this is a big change, see the [Implementation plan](#implementation-plan). Right now
the server only relays block changes and doesn't know about blocks, inventories or terrain.
## Creating a mod
```sh
deno task new-mod copper_tools "Copper Tools" # copies templates/mod to mods/copper_tools
deno task check-mods # validates every mod in mods/
```
The template has one of everything: a block with a custom component, an item, a shaped recipe, textures, and all three
scripts. `check-mods` checks manifests and data files against this spec, checks that every id, item and texture a mod
refers to exists (and that it depends on the mods those come from), and typechecks scripts against the API types in
`common/mod_api/`, which scripts import as `bworld/server`, `bworld/client` and `bworld/worldgen`. Folders in `mods/`
starting with `_` or `.` are ignored.
The game can't load mods yet; see the [Implementation plan](#implementation-plan).
## Mod layout
```
@@ -119,6 +136,7 @@ Only `manifest.json` is required. Data folders can have subfolders, with one def
| `scripts.server` | no | Server entry. Never sent to clients. |
| `scripts.client` | no | Client entry, sent to every player. |
| `scripts.worldgen` | no | Worldgen entry, sent to every player and also run on the server. |
| `credits` | no | A markdown file in the mod, shown on the About page. For asset licenses and thanks. |
Scripts can be `.js` or `.ts`. The build bundles each entry separately into one ES module. Code imported by both the
server and client entries is copied into both bundles, so **don't import secrets into shared code**. Anything the client
@@ -128,8 +146,11 @@ bundle imports is visible to players.
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.
- `namespace` is the mod's `id`. It must match `^[a-z0-9_]+$` and be at most 32 characters.
- Two namespaces are reserved. `bworld` belongs to the base game mod shipped in `mods/bworld` (see
[The base game as a mod](#the-base-game-as-a-mod)). `engine` belongs to the engine itself, for things every game
needs, like the block-breaking cracks. The one exception is `bworld:air`: it's the engine's empty block, but it keeps
that id because every save already contains it.
- `name` must match `^[a-z0-9_]+$`.
- A mod only registers ids in its own namespace: blocks, items, components, screens, channels and HUD elements. It can
_refer to_ any id.
@@ -144,7 +165,9 @@ program**, so the server and each client can number blocks differently. Saves an
`_`, 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.
- A missing texture shows the magenta and black checker (`engine:missing`) and logs a warning. It isn't a load error.
- The engine's own textures use the `engine` namespace: `engine:missing` and the breaking cracks `engine:break_0` to
`engine:break_8`. They come from `assets/`, not from a mod.
## Blocks
@@ -167,20 +190,22 @@ program**, so the server and each client can number blocks differently. Saves an
}
```
| 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. |
| Field | Default | Maps to `BlockRegistry` | Meaning |
| ---------------------- | ----------- | ----------------------- | -------------------------------------------------------------------------------------------- |
| `id` | required | `id` | The block's id. |
| `textures` | required | `textures` | One texture id, `{ top, bottom, side }` or `{ front, side }`. |
| `transparent` | `false` | `transparent` | Drawn in the transparent pass, neighbors' faces stay visible. |
| `alpha` | `1` | `alpha` | Opacity for transparent blocks. |
| `collision` | `true` | `has_collision` | Whether entities collide with it. |
| `mining.toughness` | unbreakable | `toughness` | Seconds to break by hand. A matching tool is 2× faster. |
| `mining.tool` | none | `tool_to_break` | Tool type that speeds it up, like `pickaxe`, `axe` or `shovel`. |
| `mining.requires_tool` | `false` | `requires_tool` | Only drops when broken with `mining.tool`. |
| `drops` | nothing | `drop_table` | Item id given when broken. |
| `item` | `true` | `register_block_item` | Also register an item that places this block, with the same id. |
| `interactive` | `false` | `interactive` | Right clicking it does something (opens a screen), so clients don't guess it places a block. |
| `replaceable` | `false` | new | Placing a block into it replaces it, like water. |
| `states` | none | `states` | Up to 16 bits of named state, like `hoed_dirt` has now. |
| `components` | none | the `on_*` hooks | Custom components with parameters, handled by server scripts. |
The client needs this data too (for meshing, mining time and collision), so it's sent to every player. It can't contain
functions.
@@ -235,13 +260,28 @@ clients as part of the container it's in, so client screens can show it.
}
```
| `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` |
| `type` | Fields | Notes |
| --------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| `shaped` | `pattern` rows (space is empty), `key` letter to item id, `result` `{ id, count }` | Fits anywhere in the 3×3 grid, like the recipes in `server/game/crafting.ts` |
| `furnace` | `input` item id, `output` `{ id, count }`, `cook_time` in ticks | Same as `FURNACE_RECIPES` in `server/game/blocks.ts` |
| `fuel` | `item` id, `burn_time` in ticks | Same as `FUEL_VALUES` in `server/game/blocks.ts` |
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.
A shaped recipe, for the crafting grid in the player's inventory screen:
```json
{
"format_version": 1,
"recipe": {
"type": "shaped",
"pattern": ["PPP", "P P", "PPP"],
"key": { "P": "bworld:planks" },
"result": { "id": "bworld:chest", "count": 1 }
}
}
```
Two furnace recipes with the same `input`, or two shaped recipes with the same pattern and key, are a load error. Server
scripts can also register recipe types of their own through `ctx.recipes`.
## Server scripts
@@ -353,6 +393,7 @@ interface Player {
readonly position: { x: number; y: number; z: number };
readonly inventory: Container; // 36 slots, hotbar is 0-8
readonly selected_slot: number;
readonly held_item: ItemStack | undefined;
give_item(id: string, count?: number, data?: unknown): void;
send_message(text: string): void;
teleport(x: number, y: number, z: number): void;
@@ -656,18 +697,75 @@ ctx.net.send("copper_tools:reset_stats", {});
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`:
Each chunk is generated in three passes:
1. **Terrain.** One terrain generator fills in the ground, water and trees.
2. **Ores.** Every mod's `worldgen/ores.json`, in load order.
3. **Features.** Every mod's registered features, in load order.
### Terrain generators
A world uses exactly one terrain generator, registered by a worldgen script. The base game's is `bworld:overworld`.
Which one a world uses is saved with the world. A new world uses the only one installed, or the server's `TERRAIN`
setting when there are several. A server with no terrain generator refuses to start.
```ts
export function setup(gen: WorldgenContext) {
gen.register_terrain("bworld:overworld", (chunk) => {
const height_noise = chunk.noise_2d("height");
for (let x = 0; x < 16; x++) {
for (let z = 0; z < 16; z++) {
const wx = chunk.x * 16 + x;
const wz = chunk.z * 16 + z;
const height = Math.floor((height_noise(wx * 0.01, wz * 0.01) + 1) * 15 + 50);
for (let y = 0; y <= height; y++) {
chunk.set_block(wx, y, wz, y === height ? "bworld:grass" : "bworld:stone");
}
chunk.set_height(wx, wz, height);
chunk.set_biome(wx, wz, "bworld:plains");
}
}
});
}
```
A terrain generator gets a `TerrainChunk`: a `FeatureChunk` (below) plus `set_height(x, z, height)` and
`set_biome(x, z, biome)`, which feed `height_at` / `biome_at` in later passes. Its `set_block` can write up to one chunk
away, like trees. Writes into another chunk only fill air, so the result doesn't depend on which chunk generates first.
Seeding is exact, so a generator ported from the current code produces the same terrain:
- `noise_2d(name)` is `create_noise_2d(new Alea(seed + "_" + name))` from `@paulaboks/rng`, and `noise_3d` is the same
with `create_noise_3d`. Both are cached per seed and name.
- A terrain generator's `rng` is `new Alea(seed + "_chunk_" + x + "_" + z)`.
- A feature's `rng` is `new Alea(seed + "_feature_" + feature_id + "_" + x + "_" + z)`.
### Ores
`worldgen/ores.json` uses the same fields as the `ORES` table in `common/generation.ts`, plus the block they replace:
```json
{
"format_version": 1,
"ores": [
{ "id": "copper_tools:rich_copper_ore", "min_y": 5, "max_y": 40, "scale": 0.05, "threshold": 0.72 }
{
"id": "copper_tools:rich_copper_ore",
"replaces": "bworld:stone",
"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:
Each block in the chunk that is `replaces` and between `min_y` and `max_y` becomes the first ore whose `noise_3d(id)` at
`(x, y, z) * scale` is above `threshold`.
### Features
`scripts.worldgen` registers features, which run on each chunk after terrain and ores:
```ts
import type { WorldgenContext } from "bworld/worldgen";
@@ -882,6 +980,130 @@ class SmelterStats implements ModScreen {
}
```
## The base game as a mod
Everything that makes bworld _bworld_ (its blocks, items, recipes, textures, block behavior and terrain) moves into a
mod at `mods/bworld`, written against the same API as any other mod. The engine keeps only what every game built on it
needs.
This is how the mod API gets tested for real: if the base game can't be written as a mod, the API is missing something,
and other mods would hit the same wall. It also means there's one way content works instead of two, so mods can do
anything the base game does.
### What moves
| Content | Now | In `mods/bworld` |
| ------------------------------------ | ------------------------------ | --------------------------------------------------------------- |
| 18 blocks | `common/blocks/*.ts` | `blocks/*.json` |
| 11 items | `common/items/*.ts` | `items/*.json` |
| 62 textures | `assets/sprites/textures/` | `textures/` |
| 5 crafting recipes | `server/game/crafting.ts` | `recipes/*.json`, type `shaped` |
| 6 furnace recipes, 2 fuels | `server/game/blocks.ts` | `recipes/*.json`, types `furnace` and `fuel` |
| Hoeing grass and dirt | `server/game/blocks.ts` | component `bworld:hoeable`, params `{ tool, into }` |
| Chest | `server/game/blocks.ts` | component `bworld:storage`, params `{ rows }` |
| Furnace (smelting, fuel, its screen) | `server/game/blocks.ts` | component `bworld:furnace` |
| Watering can's starting water | `common/items/watering_can.ts` | item component `bworld:watering_can`, params `{ max_water }` |
| Terrain, biomes and trees | `common/generation.ts` | terrain generator `bworld:overworld` in `scripts/worldgen.ts` |
| Ore table | `common/generation.ts` | `worldgen/ores.json`, if it generates identically (see phase 3) |
| Texture credits (the Kenney packs) | `assets/ASSETS.md` | `CREDITS.md`, listed in the manifest's `credits` |
Not moved:
- **Crops** (`common/blocks/crops.ts`) are unfinished and mostly commented out. They stay where they are and get rebuilt
as a component once server scripts exist, as a good first test of tile data plus ticking.
- **The watering can's lore** (`get_lore`) isn't shown anywhere yet, because the game has no item tooltips. It moves
once tooltips exist.
### What stays in the engine
Rendering, physics and player controls; chunks, meshing and the generation passes (noise helpers, chunk assembly, ores,
features); networking, saving and the game server; registries and the mod loader; inventories, the crafting grid, slot
click rules and the generic container screen; chat and `/give`; `bworld:air`; and the engine assets in `assets/`: the UI
sprites, font, player sprite and the `engine:` textures (the breaking cracks and the missing texture).
### Rules that keep existing worlds working
Worlds saved before the move must load afterwards with nothing changed:
1. **Ids don't change.** Saves store blocks and items by string id, including player inventories and chest contents. The
JSON uses exactly the ids the TypeScript files register now.
2. **Terrain is identical, block for block.** A save only stores what players changed _on top of_ generated terrain. If
the generator's output changes at all, every existing world silently changes with it: trees move and blocks players
broke reappear. The golden terrain test (phase 0) enforces this.
3. **Tile data keeps its shape.** Chests store their items in `containers.main`, and furnaces keep `containers.main`
plus `progress`, `progress_max`, `fuel` and `fuel_max` in `data`. The components read exactly those, so tiles saved
by the current code load into them.
4. **The save format and protocol don't change.** Clients receive `mods/bworld` like any other mod.
### Phases
Each phase ends with every test passing and the game playable.
**Phase 0: safety net.** Do this first, before any other mod work. Started: `deno task test` runs `tests/`, which so far
checks `mods/bworld` against the game and the mod tools.
- Move the test scripts used while building steps 1–3 into the repo as `deno test` files under `tests/`: server logic
(breaking, placing, crafting, chest, furnace, saves), client and server terrain agreement, click prediction, and the
end-to-end WebSocket test.
- Record golden fixtures from the current code: `tests/fixtures/registry.json`, every block and item with all its fields
(functions left out), and `tests/fixtures/terrain.json`, a SHA-256 of the final blocks of 64 chunks for 3 seeds,
including negative coordinates and chunks with trees on their borders.
- Save a world from the current code with chests, a running furnace and some player inventories as
`tests/fixtures/world_v2.json`, with a test that loads it and checks everything is where it was.
**Phase 1: data** (after steps 4 and 5).
- 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. Done: `deno task export-bworld` writes them, and
`tests/bworld_mod_test.ts` fails if they drift from the game.
- The loader registers the recipes and `server/game/crafting.ts` matches against them. Behaviors stay in
`server/game/blocks.ts`, still keyed by block id, for now.
- Delete `common/blocks/` and `common/items/`.
- Check: the registry built from JSON equals `registry.json`, and all tests pass.
**Phase 2: behavior** (after step 6, and container screens from step 7).
- `mods/bworld/scripts/server.ts` registers `bworld:hoeable`, `bworld:storage`, `bworld:furnace` and
`bworld:watering_can`. The chest and furnace open their screens with `ctx.ui.open_container`, whose layouts are the
`ScreenLayout`s the server sends now.
- The block JSON lists the components. Delete `server/game/blocks.ts`.
- Check: the server logic tests (hoeing, chest, furnace smelting, breaking a chest gives its contents back) and
`world_v2.json` still pass.
**Phase 3: world generation** (after step 9).
- Port `generate_chunk` to `mods/bworld/scripts/worldgen.ts` as the `bworld:overworld` terrain generator. It's a
straight port: the seeding rules in [Terrain generators](#terrain-generators) were chosen so the same noise names give
the same values.
- Keep trees inside the terrain generator rather than making them a feature. Right now a tree's leaves can be
overwritten by later columns of the same chunk; as a feature running after all terrain, they would win instead, and
the terrain would change.
- Try moving the ores to `ores.json` with `"replaces": "bworld:stone"`. The current code only places ores in stone, so
this should be identical, but only the golden test can say so. If it isn't, the ores stay inside the terrain
generator.
- Remove the content from `common/generation.ts`, leaving the passes and noise helpers.
- Check: `terrain.json` matches exactly, client and server terrain still agree, and `world_v2.json` loads unchanged.
**Phase 4: engine cleanup.**
- Replace the hardcoded water checks in `client/systems/player_controls.ts` and `server/game/game_server.ts` with the
`replaceable` block field.
- `/give` stops assuming `bworld:`. It looks names up across all namespaces and asks for the full id when two mods have
the same name.
- Rename engine asset keys like `bworld:ui` and `bworld:m6x11` in `client/main.ts` to `engine:`, so `bworld:` only means
content.
- The About page shows the engine's `assets/ASSETS.md` plus every loaded mod's credits.
- The server refuses to start without a terrain generator, naming the mods that could provide one.
### Done when
- Searching `client/`, `common/` and `server/` for `"bworld:` finds only `bworld:air`.
- Removing `mods/bworld` gives an engine that starts and says it needs a terrain generator, instead of crashing
somewhere.
- All tests pass, the golden fixtures are unchanged, and a world saved before the move plays exactly the same after it.
## Implementation plan
Steps 1–3 are done; the mod steps (4 onwards) aren't started. Each step keeps the game working:
@@ -911,6 +1133,9 @@ Steps 1–3 are done; the mod steps (4 onwards) aren't started. Each step keeps
Steps 1–3 are engine work every multiplayer feature needs, with or without mods. Mods could start with data only (step 4
plus data in step 5) before scripts exist.
Moving the base game into `mods/bworld` happens alongside steps 4–9. See
[The base game as a mod](#the-base-game-as-a-mod) for which phase follows which step.
## Open questions
- **Chunk delivery.** Clients generate terrain from the seed, which needs deterministic worldgen and trusts clients to