clone

a browser grid puzzle game where you push words around to rewrite the rules of each level

main
miau-is-true / docs / modding.md
modding.md 12.5K rendered
1# Creating a mod23Mods are local CommonJS source files. The browser host reads a `.cjs` file as4text and passes it to `loadMod` from `src/mods.js`. A mod is trusted code: it is5executed with `new Function`, so only load files the player chose or files that6you shipped with the game. The loader never fetches a URL and does not provide7Node's `require` or filesystem APIs. Since this is browser JavaScript, a trusted8mod can still see the normal global browser environment; use a separate worker9or iframe sandbox if you need to run untrusted code.1011## Smallest useful mod1213```js14module.exports = {15 manifest: { id: 'hello-miau', name: 'Hello Miau', version: '1.0.0' },16 palette: { MIAU: '🐈', FLAG: '🚩' },17 levels: [{18 id: 'intro',19 width: 5,20 height: 3,21 objects: [22 { kind: 'word', text: 'MIAU', x: 0, y: 0 },23 { kind: 'word', text: 'IS', x: 1, y: 0 },24 { kind: 'word', text: 'YOU', x: 2, y: 0 },25 { kind: 'object', noun: 'MIAU', x: 0, y: 1 },26 ],27 }],28};29```3031The engine recognizes `word` records as rule text and `object` records as32things in the puzzle. Word and noun values are normalized to uppercase. A33level's `id` is its stable lowercase identity within the pack; changing a34title does not change that identity. `width` and `height` are cell counts, and35every object position must be inside that rectangle. Multiple objects may36share a cell.3738## Module contract3940The exported object has these fields:4142| Field | Required | Meaning |43| --- | --- | --- |44| `manifest` | yes | `id`, `name`, and semantic `version`; optional `engine` must be `1`; optional `features` lists required host features |45| `levels` | yes | One or more level objects |46| `palette` | no | Uppercase noun to display string, or `{ glyph, faces: 'left' \| 'right' }` for an explicitly directional glyph |47| `vocabulary` | no | Uppercase noun tokens used by the mod |4849Manifest IDs use lowercase letters, numbers, `.`, `_`, and `-`, and must start50with a letter. Tokens use uppercase letters, numbers, `_`, and `-`. `IS` and51`AND`, `YOU`, `PUSH`, `STOP`, `WIN`, `SINK`, and `DEFEAT` are reserved rule52tokens. They may appear as word text, but cannot be object nouns, palette keys,53or vocabulary entries. Every level requires a unique lowercase `id` and may54also include `title` and `hint`. A pack can contain at most 64 levels, each55with at most 4096 entities. The local CommonJS source limit is 512 KiB.5657`manifest.features` is an optional array of unique lowercase feature IDs. The58current host supports `automata`. A new automata pack should declare59`features: ['automata']`; existing version 1 packs and links without the field60remain valid. A required feature the host does not support stops import with61β€œThis pack needs a newer Miau.” A future mechanic that older hosts must reject62also needs a higher `manifest.engine` version, because an older host may ignore63an unfamiliar manifest field.6465Palette strings never flip. A mod that opts into facing may use a bounded66object entry instead:6768```js69palette: {70 CAT: { glyph: '🐈', faces: 'left' },71 FLAG: '🚩',72}73```7475`faces` is only `left` or `right`; the object form must include a non-empty76`glyph` of at most 16 characters. Shared packs preserve this metadata.7778## The `@miau/engine` helper7980For less repetitive level declarations, the loader provides one deliberately81small built-in module:8283```js84const { word, object, level } = require('@miau/engine');8586module.exports = {87 manifest: { id: 'helper-demo', name: 'Helper Demo', version: '1.0.0' },88 levels: [level(4, 2, [89 word('CAT', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),90 object('CAT', 0, 1),91 ], { id: 'first-step', title: 'First step' })],92};93```9495`word(text, x, y, id?)` and `object(noun, x, y, id?)` return ordinary level96records. `level(width, height, objects, metadata?)` returns an ordinary level.97No other `require` target is available. An attempt to require a package or98remote module produces an error naming the unsupported import.99100## Loading and errors101102```js103import { loadMod, ModValidationError } from './mods.js';104105try {106 const mod = loadMod(sourceText, { filename: file.name });107 // Add mod.levels to the level browser and use mod.palette for rendering.108} catch (error) {109 if (error instanceof ModValidationError) {110 showMessage(error.errors.join('\\n'));111 } else {112 showMessage(error.message);113 }114}115```116117Validation reports every problem in one pass: malformed manifest fields,118missing levels, out-of-bounds positions, duplicate IDs, unsupported object119kinds, invalid rule tokens, and invalid palette entries. This lets an editor120show actionable errors without starting a partially loaded mod.121122The pack service also returns nonblocking `warnings` beside `errors` when a123word tile has no rule or noun meaning in its proof. This check covers loose124words that might be pushed into a sentence later. It uses nouns placed in that125proof and that proof's automata subjects, along with core and locally declared126properties. A palette entry or a noun in another proof does not make a word127meaningful here. For example, a loose `SHUT` word receives a note when `SHUT`128is neither a noun nor a property in the proof. If the player forms129`DOOR IS SHUT`, the engine interprets `SHUT` as a noun transformation; the note130explains that consequence. Each record includes `word`, `levelId`,131`levelTitle`, `sentence` when formed, player-facing `message`, and technical132`detail`. Authors can still install and play a pack with notes.133134Automata properties are declared per proof. The same token cannot also be an135object noun in that proof. A custom property declared only in another proof136has no behavior here, and its word tile receives a note.137138## Rule precedence and stacked words139140These rules make a proof deterministic when several properties, transforms, or141word tiles meet in one turn. Authors can use them when checking a level by142hand, and solvers should use the same order.143144| Situation | Resolution |145| --- | --- |146| One entity has both `PUSH` and `STOP` | `PUSH` takes precedence for that entity. |147| A cell contains `DEFEAT` and `WIN` contact | Resolve `DEFEAT` first, including a singleton `YOU` entity that also has `DEFEAT`; it is removed before it can win. |148| A noun has both `X IS X` and `X IS Y` | The identity transform suppresses the other transform for `X`. |149| Several spatial noun transforms compete | Select the first valid transform by source position, scanning top-to-bottom and then left-to-right. At one source cell, horizontal reads before vertical. |150| A turn could apply multiple noun transforms to one object | Apply at most one noun-transform link to that object in that turn. |151| A sentence path crosses a stacked word cell | Select one word from that cell for the path, while retaining every valid alternative sentence. A sentence may begin from a stacked cell. |152153The last rule applies independently to each sentence path. Stacking therefore154does not silently discard a valid reading, but a single path cannot consume two155words from the same cell.156157The engine rejects a proof whose initial stacks exceed its rule-reading158complexity budget. If movement creates such a stack later, that turn is blocked159without changing the board or advancing automata.160161## Bundled teaching packs162163The repository includes two authored packs in addition to the three core164levels:165166- `mods/garden-basics.cjs` displays as **Basic Proofs**. Its stable internal167 pack ID remains `garden-basics`, with level IDs `garden-push`,168 `garden-pond`, and `garden-thorns`. The levels isolate PUSH, SINK, and169 DEFEAT, with a winning route for each.170- `mods/word-lab.cjs` (`word-lab`) contains `shared-control`,171 `change-what-you-are`, and `rules-are-cargo`. The levels cover shared YOU,172 noun transformation, and editing a stacked predicate.173174Together with the core pack, these provide nine verified level proofs. The175tests load each CommonJS file, create an engine game from every level, and176replay a concrete winning intent sequence.177178## Checking proofs with the author solver179180The author CLI runs the same movement, rule, automaton, and contact phases as181play while keeping compact states in its search frontier:182183```sh184node tools/solve.mjs core185node tools/solve.mjs mods/word-lab.cjs --level rules-are-cargo --max 5000186node tools/solve.mjs mods/word-lab.cjs --level rules-are-cargo --replay RRRRRRR187node tools/solve.mjs mods/worlds/crossword.cjs --level pass-it-on --forbid "ROCK IS WIN"188```189190`--max` limits attempted actions; `--max-visited` separately limits retained191frontier states. A `LIMIT` result means the cap was reached, not that the proof192is unsolvable. Without `--max`, a proof's optional `solverBudget` sets its cap;193otherwise the default is 2,000,000 attempts. An explicit `--max` overrides194both. `--forbid` can be repeated and excludes a rule if it was active195initially or at any rule-reading phase within a turn, even if it disappears196before settlement. Replay prints each board using the public engine path.197198The CLI runs separate constrained searches for its current flags. `BYPASS`199means a win exists without the player pushing a word; `HANDS-OFF` means a win200exists with no word motion from either player or automata. A route's own201classification does not establish whether another route has either property.202World authors can declare optional `solverBudget`, `allowBypass`,203`allowHandsOff`, and `mustForm` fields on a proof. `solverBudget` is a positive204integer. The two `allow` booleans silence the corresponding warning when an205unusual route is intentional. `mustForm: ['ROCK IS WIN']` asks the CLI to find206any winning route that never uses that rule. A found route prints207`SKIPPABLE(ROCK IS WIN)` and fails; a capped search prints `LIMIT(ROCK IS WIN)`208and also fails because necessity remains unproven. The bundled-world CI gate209is being integrated with the worlds team's authored budgets.210211The bundled proof gate runs the shipped CommonJS worlds in sequence so each212proof gets its full authored solver budget without competing for memory:213214```sh215npm run check:worlds216```217218It checks that every proof solves, every `mustForm` rule is necessary, and219every constrained BYPASS search is either allowed or rejected. A `LIMIT` in a220normal, BYPASS, or `mustForm` search fails the gate because the claim is not221proven. HANDS-OFF remains a warning: it describes an unusual but valid route222and does not block a pack from shipping. The command loads the CommonJS packs223and calls the structured solver API directly, so its result does not depend on224CLI text parsing. Crossword is intentionally outside this first gate while its225world content is being reworked.226227## Pack service and sharing228229`src/pack-service.js` provides the presentation-independent pack operations:230231`listPacks`, `importPackText`, `exportPack`, `packLink`, `decodePackLink`,232`installPack`, and `removePack`.233234Import and link decoding return a preview and do not change the catalog. The235caller must explicitly pass the preview to `installPack`; duplicate IDs can be236replaced or kept as a fork with a new ID and `forkOf` metadata. URL fragments237are data-only and never execute CommonJS source.238239A valid shared URL enters a pending Gift state. **Keep and play** is the240explicit install action; leaving the Gift state preserves the existing catalog.241Local imports follow the same flow: Workshop, Import, Gift preview, then242explicit **Keep and play**. Workshop also offers Play, Share link, Export243`.cjs`, and Remove. Installed authored pack data is persisted through the pack244library and reloaded without evaluating CommonJS. The service and pending245install contract are covered by unit and browser tests. The246[stable public stage](https://fcbc.n.zip/) includes the verified sharing flow;247engine and preview revisions are recorded in `docs/roadmap.md`.248Workshop is reachable directly from Title and INDEX. The designer accepted249the stage visual gate.250251## OpenSpec status252253Physical sentence spans are shipped and archived under254`openspec/changes/archive/2026-09-23-expose-sentence-spans/`. Pack sharing is255archived under `openspec/changes/archive/2026-09-23-share-level-packs/`.256Same-entity `YOU` and `WIN` resolution is archived under257`openspec/changes/archive/2026-09-24-fix-same-entity-win/`. The full-screen258stage is archived under259`openspec/changes/archive/2026-09-24-build-ink-signal-stage/` after designer260acceptance.261262## Sentence spans for renderers263264The engine keeps semantic rules and physical sentences separate. A rule record265contains its source word IDs and `axis` (`h` or `v`); `state.sentences` lists266every maximal physical span, including exact duplicate sentences. Renderers267can draw sentence bars from those spans without reparsing the board, while268rule ledgers can continue to deduplicate identical semantic rules.