New modding system

This commit is contained in:
2026-09-26 17:33:07 -03:00
parent 86d5c39eab
commit 243ff52063
25 changed files with 1089 additions and 512 deletions
+59 -30
View File
@@ -110,7 +110,9 @@ mod refers to exists (and that it depends on the mods those come from), and type
`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.
`deno task build` packs every mod in `mods/` into a `.bmod` in `server_mods/` and fails if any has errors;
`deno task server` then loads every `.bmod` there. `deno task pack-mod mods/copper_tools` packs one mod into a file to
share, see [Mod files](#mod-files).
## Mod layout
@@ -118,6 +120,7 @@ with engine access, `bworld/engine`. Folders in `mods/` starting with `_` or `.`
mods/
copper_tools/
manifest.json
deno.json # optional: the mod is its own deno project, see Mod files
blocks/*.json
models/*.json
items/*.json
@@ -201,8 +204,7 @@ program**, so the server and each client can number blocks differently. Saves an
- 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.
- Textures are 16×16. They go in the mod's `.bmod`, and clients put every mod's textures into one atlas when they join.
- A missing texture shows the magenta and black checker (`engine:missing`) and logs a warning. It isn't a load error.
- The engine's own textures use the `engine` namespace: `engine:missing` and the breaking cracks `engine:break_0` to
`engine:break_8`. They come from `assets/`, not from a mod.
@@ -1228,24 +1230,46 @@ function hook<T, K extends keyof T>(target: T, method: K, handlers: {
## Delivery to clients
### Build
### Mod files
`deno task build` builds each mod in `mods/` into two places:
A mod is shipped as a `.bmod` file: the mod, built, in one zip. Servers load every `.bmod` in their `server_mods/`
folder, so installing a mod is dropping its file there and restarting.
```
build/mods/<id>/<hash>/ # public, served to players
manifest.json
data.json # all blocks, items, models, recipes, overrides and ores merged
common.js
client.js
worldgen.js
server_mods/<id>/<hash>/ # private, outside the static root, never served
server.js
```sh
deno task pack-mod mods/copper_tools # writes copper_tools.bmod
deno task build # packs every mod in mods/ into server_mods/<id>.bmod
```
`<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.
```
copper_tools.bmod
manifest.json # the mod's manifest, with scripts and credits pointing into the zip
data.json # every block, model, item, recipe and ore, checked when it was packed
scripts/server.js # each script bundled into one module
scripts/client.js
scripts/worldgen.js
textures/<name>.png # the texture copper_tools:<name>
credits.md
```
- A mod is a Deno project. When its folder has a `deno.json`, its scripts are bundled with it, so its own imports and
npm or JSR packages work and end up in the bundle. Mods without one use the `deno.json` of the folder the build runs
in, like the ones in this repository.
- Packing checks the mod first, like `check-mods`, and refuses to pack one with errors.
- Packing the same mod twice gives the same bytes, so the file's hash only changes when the mod does.
- `deno task build` leaves other `.bmod` files in `server_mods/` alone, so mods from elsewhere can sit next to the ones
it builds.
When the server starts it opens every `.bmod`, checks it again (it may come from anyone): its manifest and data, its
textures, the ids it uses from other mods, and that its dependencies are there. A problem in any mod stops the server
from starting, saying which mod and what's wrong, so the game never runs with some mods missing. Then it sorts them so
dependencies load first.
Players get each mod as a `.bmod` too, **without its server script**: the server makes a copy without
`scripts/server.js` and serves it at `/mods/<sha256>.bmod`, named by its hash so it can be cached forever. The code is in
`common/bmod.ts` (reading and writing), `tools/pack_mod.ts` and `server/load_bmods.ts`.
The engine's own files (the client, its UI sprites and font, and the `engine:` textures) aren't in any mod. They're in
`build/`, which the server serves as it is.
### Joining
@@ -1253,15 +1277,14 @@ server's atlas, which gets a hash-named URL the same way.
client server
│ hello { name, protocol } │
├────────────────────────────────────────►│ a different protocol gets rejected { reason }
│ welcome { protocol, seed, atlas, │ mods[i] = { id, name, version, hash,
│ mods[] } │ data, client?, worldgen?, sha256 }
│◄────────────────────────────────────────┤ atlas = { png, json, sha256 } (paths on the server)
│ welcome { protocol, seed, mods[] } │ mods[i] = { id, name, version, sha256, file, size }
│◄────────────────────────────────────────┤ file is the .bmod's path on the server
│ │
│ [cross-origin: confirm screen] │
│ download the atlas and every mod file, │
│ check each one's sha256, load content │
│ in the listed order, run client │
│ setup(), start chunk workers │
│ download every .bmod, check each one's │
│ sha256, build the texture atlas, load │
│ content in the listed order, run │
│ client setup(), start chunk workers │
│ │
│ ready { registry_hash } │ a different hash gets rejected { reason }
├────────────────────────────────────────►│
@@ -1271,17 +1294,20 @@ client server
```
- The client registers each mod's data **in the order the server lists them**.
- Every file is checked against its SHA-256 before it's used, and scripts are imported from the checked bytes (as
- Every `.bmod` is checked against its SHA-256 before it's opened, and scripts are imported from the checked bytes (as
`blob:` URLs), so what runs is exactly what was checked. The client code is in `client/handshake.ts` and
`client/mods.ts`.
- The client builds the texture atlas itself, from the engine's textures and the ones in the `.bmod` files
(`client/atlas.ts`). The server never needs to decode an image.
- If any download, hash check or `setup` fails, the client disconnects and shows which mod failed. It never joins with
some mods missing.
- The `protocol` in `hello` is the game's protocol version (`PROTOCOL_VERSION` in `common/protocol.ts`). A mismatch is
rejected before anything is downloaded.
- Until `ready`, the connection isn't a player: other players don't see it, and anything it sends besides `ready` is
ignored. A client that doesn't send `ready` within 60 seconds is rejected.
- Mod files and the atlas are served with `Access-Control-Allow-Origin: *` and cached for a year, since their paths
change whenever their content does.
- `.bmod` files are served with `Access-Control-Allow-Origin: *` and cached for a year, since their paths change
whenever their content does. The engine's assets are served with `Access-Control-Allow-Origin: *` too, for pages
from other origins.
- Leaving a server reloads the page, so one server's mod code never stays loaded while playing on another.
## Security
@@ -1294,7 +1320,7 @@ where the page came from:
- **Page from one origin, game server on another** (a different server typed in the title screen, or `?server=`): mod
code from that server runs with _the page's_ origin, including its local storage. The client shows the server's mod
list and asks before loading anything, and remembers the answer per server and mod hash. The server needs CORS
headers on `build/mods/`.
headers on its `.bmod` downloads and assets, which it sends.
- The hash check confirms the files are the ones the server listed. It doesn't protect against a malicious server.
**Engine access.** A server mod with engine access controls the game server worker completely, but the worker still
@@ -1307,6 +1333,9 @@ lists which mods use engine access.
`--unstable-worker-options`). They can't read files, open connections or run programs. Everything they need goes through
the mod API. Installing a server mod still means trusting it with the game world and everything players send.
**Server scripts stay on the server.** Players get each mod's `.bmod` without its server script, so a mod can keep
anti-cheat logic or anything else private there.
**Players.** Clients are untrusted. The server checks reach and the item being held for every break, place and interact,
applies container rules on the server, validates form responses, and rate-limits mod channels. Server script code and
tile data are never sent to clients.
@@ -1474,8 +1503,8 @@ Worlds saved before any of this must load afterwards with nothing changed:
## Implementation plan
**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,
**Done so far:** the mod loader, `.bmod` mod files (packing, loading them from `server_mods/`, sending players a copy
without the server script), delivery to clients (hashed downloads, the confirm screen for other servers, credits); the data layer (blocks, block models with variants, items, recipes, ores); server scripts with components,
events, commands, timers, storage, recipes, containers and container screens; worldgen features and ores; the base
game's block behavior as a mod; world time with day and night. The code is in `common/mod_loader.ts`,
`common/mod_data.ts`, `server/game/mod_runtime.ts`, `client/mods.ts` and `common/worldgen_loader.ts`.