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/. 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/displays as Basic Proofs. Its stable internal pack ID remainsgarden-basics.cjs garden-basics, with level IDsgarden-push,garden-pond, andgarden-thorns. The levels isolate PUSH, SINK, and DEFEAT, with a winning route for each.mods/(word-lab.cjs word-lab) containsshared-control,change-what-you-are, andrules-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/ 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/.
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/. Pack sharing is
archived under openspec/.
Same-entity YOU and WIN resolution is archived under
openspec/. The full-screen
stage is archived under
openspec/ 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.