10 KiB
PACKS.md — content packs
A content pack is add-on content a server picks up from a folder: a manifest plus its files. The owner's decision of
2026-10-07 made the language pack the first type of one general mechanism, so that later resource packs (art, audio,
fonts) and server-side data packs reuse the same manifest, registry and index instead of each inventing its own. In
0.2.0 one type loads, lang (a language: docs/I18N.md "Adding a language"); assets and data are planned.
| File | Role |
|---|---|
shared/packs.js |
the format: the manifest, the pack types (PACK_TYPES), app version ranges, the index (browser, server and tools) |
shared/i18nPacks.js |
the lang type: language codes, the language fields, the fallback chain, the script family |
server/packs.js |
the registry: discovery, validation, the index, which pack files may be served |
server/http/static.js |
GET /packs/index.json (the live index) and GET /packs/<id>/<file> |
public/js/ui/lang.js |
the client of the lang entries: the language menu and the loader |
tools/packs.mjs |
list, index (the index file for a static host), check |
tools/package.mjs |
ships the committed packs and writes packs/index.json into the release zips |
1. Layouts
Two layouts, read by the same registry and listed alike:
packs/<id>/pack.json + its files a folder pack — any type; the manifest names its files
public/i18n/<code>.json a single-file language pack — its manifest is the file's `_meta` block
data/i18n/<code>.json (+ its game texts, when present)
The single file is the quickest way for a translator (one file, the format English has always had, and how English ships); the folder is the layout every pack type shares — a pack distributed as one folder with its own readme and licence, and the only layout a resource or data pack will have. A language can come either way; the menu does not tell them apart.
The id of a folder pack is its folder name; the id of a single-file language pack is its language code. Ids are
letters, digits, ., _ and - (at most 64), unique across types and without regard to case (Windows and macOS
would see one folder).
2. The manifest
packs/<id>/pack.json, or the _meta block of public/i18n/<code>.json (the same fields):
{
"type": "lang",
"lang": "ja",
"name": "日本語",
"englishName": "Japanese",
"version": "1.0.0",
"app": ">=0.2.0",
"authors": ["…"],
"credits": "…",
"license": "CC-BY-4.0",
"fallback": ["en"],
"files": { "ui": "ui.json", "data": "data.json" }
}
| Field | Meaning |
|---|---|
id |
optional: the folder or file name decides; a manifest that names another id gets a warning |
type |
lang (a single-file language pack may leave it out); planned: assets, data |
version |
the pack's own version |
app |
the app versions it is made for, a range: >=0.2.0, >=0.2.0 <0.3.0, 0.2.x, ^0.2.0, ~0.2.1, *, alternatives with ` |
name, englishName |
the name in its own language (a language pack: the language's own name, shown in the menu) and in English (the menu's tooltip) |
authors, credits, license |
who made it; free text (license: an SPDX id or text) |
files |
a folder pack: role → path inside the folder. Each type lists its roles and their extensions (lang: ui required, data optional, both .json) |
| the type's fields | lang: lang, base, fallback, complete, numberUnits (docs/I18N.md) |
A problem keeps a pack from loading (unknown type, no lang, a file missing or outside the folder, broken JSON, an
id or a language already taken — the language folders' files come first, then the folders by id); a warning does
not (an id that differs, an app range this version is outside of, an unknown file role). Both are printed when the
server starts or notices a change, and by node tools/packs.mjs list.
3. Discovery and the index
The server's registry (server/packs.js) scans public/i18n/, data/i18n/ and packs/ when it starts and again
whenever something there changes: on a request for the index or a pack file it compares the names, sizes and times of
the files it reads (at most once a second, a few stat calls) and scans again if they differ. A pack dropped into a
folder shows on the next page load — no restart, no build step; a removed one leaves (a player whose stored language is
gone gets Chinese).
GET /packs/index.json (never cached) answers the packs of supported types:
{ "version": 1, "app": "0.2.0", "packs": [
{ "id": "en", "type": "lang", "name": "English", "englishName": "English", "version": "0.2.0", "app": ">=0.2.0",
"compatible": true, "authors": ["…"], "lang": "en", "fallback": [], "complete": true, "strings": 1017,
"files": { "ui": "/i18n/en.json", "data": "/data/i18n/en.json" } } ] }
files holds URLs on this server, so a client never builds a path itself. A client reads the entries of its type
(readPackIndex(json, 'lang'): an entry of another type, with a bad id or a URL that is not a path on this server is
dropped).
A static host cannot list folders: node tools/packs.mjs index writes the same body to packs/index.json
(git-ignored), and tools/package.mjs writes it into the release zips. The live server answers /packs/index.json
itself and ignores that file. Serving the game from plain files needs the server's URL layout: public/ at /,
data/ at /data/, shared/ at /shared/, server/sim/ at /sim/ and packs/ at /packs/ (the game itself still
needs the Node server for its rooms; DEPLOY.md).
What is served. GET /packs/<id>/<file> answers only a file that the manifest of a loaded folder pack names under a
role of its type, with that role's extension, inside the folder once links are resolved — pack.json, a readme, an
HTML or script file in the folder are never served; a pack of a planned type serves nothing. A single-file language
pack's files are the language folders' own (/i18n/<code>.json, /data/i18n/<code>.json).
4. The types
| Type | Status | Where it applies | Loader |
|---|---|---|---|
lang |
supported (0.2.0) | the client: UI strings, game texts | public/js/ui/lang.js (the menu, the strings: shared/i18n.js; the game texts: public/js/data.js) |
assets |
planned | the client: art, audio, font replacements | — |
data |
planned | the server: data patches applied at start | — |
A pack of a planned type is parsed and listed as not supported ("type "assets" is planned …, not loaded by this version"); it is never served and never changes anything.
Adding a pack type
- The type — its entry in
PACK_TYPES(shared/packs.js):status,summary,live(whether a running server may take changes without a restart) andfiles(the roles a folder pack names, with the extensions that may be served for each). Its own manifest fields get a normalizer besidelangFieldsand a branch innormalizeManifest; its index fields a branch inpackIndexEntry. - The loader — reads the registry by type:
createPackRegistry(…).list('assets')on the server, or the index entries of the type in the browser (readPackIndex(json, 'assets')), the wayui/lang.jsreadslang. A server-side type (data) loads once at start (live: false): the simulation's code and data are fixed for the life of the process, and a battle must run the same data on the server and in every browser. - The checks — what
tools/packs.mjs checkand the tests verify for the type (a data pack: the same validation the data builder applies; a resource pack: the files exist and are of the allowed kinds), and its documentation here.
Things later types must keep (the maintainers' study of mods, 2026-10-07):
- Server-side only for gameplay. A data pack is installed on the server, so every player of a room uses the same content and the server can still check or recompute a battle. A player never installs gameplay content of their own.
- A content hash in the handshake before data packs ship (
welcome,/healthz, the BattleSpec), so a client and a server can tell they run the same content, and a "modded" mark in the UI and in bug reports. - No third-party code. The manifest and its files are data (JSON, images, audio, fonts); a pack type that runs code is a separate decision of the owner's.
- The golden results stay on the original content; a pack never changes them.
5. Tools
node tools/packs.mjs list [--json] the packs by type, what was skipped and why, the warnings
node tools/packs.mjs index [--out <file>] write packs/index.json (the static index)
node tools/packs.mjs check [--strict] list; --strict exits 1 when a pack is skipped or warned about
node tools/i18n.mjs template <code> a new language pack (docs/I18N.md)
node tools/i18n.mjs check <code> | --all a language pack's strings: coverage, placeholders
--root <dir> points tools/packs.mjs at another checkout or an unpacked release.
6. Shipping
tools/package.mjs ships the committed packs (public/i18n/, data/i18n/, packs/ — tracked files only: a pack
installed on this machine and not committed stays out) and writes packs/index.json into the stage from the packs
that ship. test/package.test.js checks both layouts and that a local pack stays out.
Tests
test/i18n-packs.test.js (discovery of both layouts, broken / planned / duplicate packs, a pack dropped in or removed
while the server runs, the file allowlist, the manifest and app ranges, the fallback chain, placeholders, plurals,
the per-text game-text fallback, the client loader and menu, the tools, build-i18n --lang / --dict),
test/package.test.js, test/ui/i18n.e2e.test.js
(SP_E2E=1: a pack file and a pack folder dropped in while the server runs show in the menu and switch).