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 source

Creating a mod

Mods are local CommonJS source files. The browser host reads a .cjs file as text and passes it to loadMod from src/mods.js. A mod is trusted code: it is executed with new Function, so only load files the player chose or files that you shipped with the game. The loader never fetches a URL and does not provide Node's require or filesystem APIs. Since this is browser JavaScript, a trusted mod can still see the normal global browser environment; use a separate worker or iframe sandbox if you need to run untrusted code.

Smallest useful mod

module.exports = {
  manifest: { id: 'hello-miau', name: 'Hello Miau', version: '1.0.0' },
  palette: { MIAU: '🐈', FLAG: '🚩' },
  levels: [{
    id: 'intro',
    width: 5,
    height: 3,
    objects: [
      { kind: 'word', text: 'MIAU', x: 0, y: 0 },
      { kind: 'word', text: 'IS', x: 1, y: 0 },
      { kind: 'word', text: 'YOU', x: 2, y: 0 },
      { kind: 'object', noun: 'MIAU', x: 0, y: 1 },
    ],
  }],
};

The engine recognizes word records as rule text and object records as things in the puzzle. Word and noun values are normalized to uppercase. A level's id is its stable lowercase identity within the pack; changing a title does not change that identity. width and height are cell counts, and every object position must be inside that rectangle. Multiple objects may share a cell.

Module contract

The exported object has these fields:

Field Required Meaning
manifest yes id, name, and semantic version; optional engine must be 1; optional features lists required host features
levels yes One or more level objects
palette no Uppercase noun to display string, or { glyph, faces: 'left' | 'right' } for an explicitly directional glyph
vocabulary no Uppercase noun tokens used by the mod

Manifest IDs use lowercase letters, numbers, ., _, and -, and must start with a letter. Tokens use uppercase letters, numbers, _, and -. IS and AND, YOU, PUSH, STOP, WIN, SINK, and DEFEAT are reserved rule tokens. They may appear as word text, but cannot be object nouns, palette keys, or vocabulary entries. Every level requires a unique lowercase id and may also include title and hint. A pack can contain at most 64 levels, each with at most 4096 entities. The local CommonJS source limit is 512 KiB.

manifest.features is an optional array of unique lowercase feature IDs. The current host supports automata. A new automata pack should declare features: ['automata']; existing version 1 packs and links without the field remain valid. A required feature the host does not support stops import with β€œThis pack needs a newer Miau.” A future mechanic that older hosts must reject also needs a higher manifest.engine version, because an older host may ignore an unfamiliar manifest field.

Palette strings never flip. A mod that opts into facing may use a bounded object entry instead:

palette: {
  CAT: { glyph: '🐈', faces: 'left' },
  FLAG: '🚩',
}

faces is only left or right; the object form must include a non-empty glyph of at most 16 characters. Shared packs preserve this metadata.

The @miau/engine helper

For less repetitive level declarations, the loader provides one deliberately small built-in module:

const { word, object, level } = require('@miau/engine');

module.exports = {
  manifest: { id: 'helper-demo', name: 'Helper Demo', version: '1.0.0' },
  levels: [level(4, 2, [
    word('CAT', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),
    object('CAT', 0, 1),
  ], { id: 'first-step', title: 'First step' })],
};

word(text, x, y, id?) and object(noun, x, y, id?) return ordinary level records. level(width, height, objects, metadata?) returns an ordinary level. No other require target is available. An attempt to require a package or remote module produces an error naming the unsupported import.

Loading and errors

import { loadMod, ModValidationError } from './mods.js';

try {
  const mod = loadMod(sourceText, { filename: file.name });
  // Add mod.levels to the level browser and use mod.palette for rendering.
} catch (error) {
  if (error instanceof ModValidationError) {
    showMessage(error.errors.join('\\n'));
  } else {
    showMessage(error.message);
  }
}

Validation reports every problem in one pass: malformed manifest fields, missing levels, out-of-bounds positions, duplicate IDs, unsupported object kinds, invalid rule tokens, and invalid palette entries. This lets an editor show actionable errors without starting a partially loaded mod.

The pack service also returns nonblocking warnings beside errors when a word tile has no rule or noun meaning in its proof. This check covers loose words that might be pushed into a sentence later. It uses nouns placed in that proof and that proof's automata subjects, along with core and locally declared properties. A palette entry or a noun in another proof does not make a word meaningful here. For example, a loose SHUT word receives a note when SHUT is neither a noun nor a property in the proof. If the player forms DOOR IS SHUT, the engine interprets SHUT as a noun transformation; the note explains that consequence. Each record includes word, levelId, levelTitle, sentence when formed, player-facing message, and technical detail. Authors can still install and play a pack with notes.

Automata properties are declared per proof. The same token cannot also be an object noun in that proof. A custom property declared only in another proof has no behavior here, and its word tile receives a note.

Rule precedence and stacked words

These rules make a proof deterministic when several properties, transforms, or word tiles meet in one turn. Authors can use them when checking a level by hand, and solvers should use the same order.

Situation Resolution
One entity has both PUSH and STOP PUSH takes precedence for that entity.
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.
A noun has both X IS X and X IS Y The identity transform suppresses the other transform for X.
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.
A turn could apply multiple noun transforms to one object Apply at most one noun-transform link to that object in that turn.
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.

The last rule applies independently to each sentence path. Stacking therefore does not silently discard a valid reading, but a single path cannot consume two words from the same cell.

The engine rejects a proof whose initial stacks exceed its rule-reading complexity budget. If movement creates such a stack later, that turn is blocked without changing the board or advancing automata.

Bundled teaching packs

The repository includes two authored packs in addition to the three core levels:

  • mods/garden-basics.cjs displays as Basic Proofs. Its stable internal pack ID remains garden-basics, with level IDs garden-push, garden-pond, and garden-thorns. The levels isolate PUSH, SINK, and DEFEAT, with a winning route for each.
  • mods/word-lab.cjs (word-lab) contains shared-control, change-what-you-are, and rules-are-cargo. The levels cover shared YOU, noun transformation, and editing a stacked predicate.

Together with the core pack, these provide nine verified level proofs. The tests load each CommonJS file, create an engine game from every level, and replay a concrete winning intent sequence.

Checking proofs with the author solver

The author CLI runs the same movement, rule, automaton, and contact phases as play while keeping compact states in its search frontier:

node tools/solve.mjs core
node tools/solve.mjs mods/word-lab.cjs --level rules-are-cargo --max 5000
node tools/solve.mjs mods/word-lab.cjs --level rules-are-cargo --replay RRRRRRR
node tools/solve.mjs mods/worlds/crossword.cjs --level pass-it-on --forbid "ROCK IS WIN"

--max limits attempted actions; --max-visited separately limits retained frontier states. A LIMIT result means the cap was reached, not that the proof is unsolvable. Without --max, a proof's optional solverBudget sets its cap; otherwise the default is 2,000,000 attempts. An explicit --max overrides both. --forbid can be repeated and excludes a rule if it was active initially or at any rule-reading phase within a turn, even if it disappears before settlement. Replay prints each board using the public engine path.

The CLI runs separate constrained searches for its current flags. BYPASS means a win exists without the player pushing a word; HANDS-OFF means a win exists with no word motion from either player or automata. A route's own classification does not establish whether another route has either property. World authors can declare optional solverBudget, allowBypass, allowHandsOff, and mustForm fields on a proof. solverBudget is a positive integer. The two allow booleans silence the corresponding warning when an unusual route is intentional. mustForm: ['ROCK IS WIN'] asks the CLI to find any winning route that never uses that rule. A found route prints SKIPPABLE(ROCK IS WIN) and fails; a capped search prints LIMIT(ROCK IS WIN) and also fails because necessity remains unproven. The bundled-world CI gate is being integrated with the worlds team's authored budgets.

The bundled proof gate runs the shipped CommonJS worlds in sequence so each proof gets its full authored solver budget without competing for memory:

npm run check:worlds

It checks that every proof solves, every mustForm rule is necessary, and every constrained BYPASS search is either allowed or rejected. A LIMIT in a normal, BYPASS, or mustForm search fails the gate because the claim is not proven. HANDS-OFF remains a warning: it describes an unusual but valid route and does not block a pack from shipping. The command loads the CommonJS packs and calls the structured solver API directly, so its result does not depend on CLI text parsing. Crossword is intentionally outside this first gate while its world content is being reworked.

Pack service and sharing

src/pack-service.js provides the presentation-independent pack operations:

listPacks, importPackText, exportPack, packLink, decodePackLink, installPack, and removePack.

Import and link decoding return a preview and do not change the catalog. The caller must explicitly pass the preview to installPack; duplicate IDs can be replaced or kept as a fork with a new ID and forkOf metadata. URL fragments are data-only and never execute CommonJS source.

A valid shared URL enters a pending Gift state. Keep and play is the explicit install action; leaving the Gift state preserves the existing catalog. Local imports follow the same flow: Workshop, Import, Gift preview, then explicit Keep and play. Workshop also offers Play, Share link, Export .cjs, and Remove. Installed authored pack data is persisted through the pack library and reloaded without evaluating CommonJS. The service and pending install contract are covered by unit and browser tests. The stable public stage includes the verified sharing flow; engine and preview revisions are recorded in docs/roadmap.md. Workshop is reachable directly from Title and INDEX. The designer accepted the stage visual gate.

OpenSpec status

Physical sentence spans are shipped and archived under openspec/changes/archive/2026-09-23-expose-sentence-spans/. Pack sharing is archived under openspec/changes/archive/2026-09-23-share-level-packs/. Same-entity YOU and WIN resolution is archived under openspec/changes/archive/2026-09-24-fix-same-entity-win/. The full-screen stage is archived under openspec/changes/archive/2026-09-24-build-ink-signal-stage/ after designer acceptance.

Sentence spans for renderers

The engine keeps semantic rules and physical sentences separate. A rule record contains its source word IDs and axis (h or v); state.sentences lists every maximal physical span, including exact duplicate sentences. Renderers can draw sentence bars from those spans without reparsing the board, while rule ledgers can continue to deduplicate identical semantic rules.