- commit
- fac956c08c62f4dd4d65268e9a50627be01500e7
- parent
- 9eec40fd0df739db27c7a09ff8a6efedaa6139a1
- author
- Dami <dami.etoile@gmail.com>
| A | docs/ | 85 | +++ |
| A | docs/ | 449 | +++++++++++++ |
| A | docs/ | 120 | ++++ |
| A | docs/ | 308 | +++++++++ |
| A | docs/ | 199 | ++++++ |
| A | docs/ | 999 | ++++++++++++++++++++++++++++++ |
| A | docs/ | 277 | ++++++++ |
@@ -0,0 +1,85 @@
1+# Worlds program: brief2+3+Owner: **design** (Claude session `miau-is-true-9e`), managing a small team of4+world-builder subagents. Engine questions go through5+[`docs/worlds-inbox.md`](../worlds-inbox.md).6+7+## Why8+9+Baba Is You's modding scene works because a creator can package a **world**:10+a themed collection of levels that explores one idea, with or without custom11+tiles and custom rules. Miau Is True should support the same pattern. This12+program is the **proof of that pattern**. We build a few real worlds, ship the13+good ones as default packs, and write down every point of friction a creator14+or player hits along the way.15+16+## Worlds in flight17+18+| World | Pack id | Explores | Custom tiles | Custom rules |19+| --- | --- | --- | --- | --- |20+| Crate Works | `crate-works` | Sokoban pushing: crates, goals, dead ends, rules that change what can be pushed | yes | no (documents what a real Sokoban goal would need) |21+| Tidewater | `tidewater` | Hazards and identity: SINK, DEFEAT, and becoming something else to cross them | yes | no |22+| Crossword | `crossword` | Pure grammar: shared words across axes, AND chains, scarce words, stacked words | no (core nouns only) | no |23+| Clockwork | `clockwork` | **Custom rules**: new properties a mod defines (for example MOVE, OPEN/SHUT) | yes | **yes** (drafted against a proposed mod behavior API) |24+25+## Status (2026-09-23)26+27+| World | Levels | Verified by design | Bundling |28+| --- | --- | --- | --- |29+| Crate Works | 7 (15 to 48 steps) | yes, `mustForm` proven on 3 | **ready**, in CI and green |30+| Crossword | 7 (16 to 35 steps) | yes, reworked for W6, `mustForm` proven on 6 | **ready** (handed off for CI and bundling) |31+| Tidewater | 7 (9 to 26 steps) | yes, `mustForm` proven on 3 | **ready**. W8 fixed (`c3537ca`) and re-solved green |32+| Clockwork | 6 (10 to 29 steps) | yes (`mouse-keeps-time` has `allowBypass`) | in CI and green. W11 landed; bundling after W12 (custom property tiles and the beat strip). OPEN/SHUT waits on W13 |33+34+The engine asks W1 to W13 are in [`docs/worlds-inbox.md`](../worlds-inbox.md), and35+the design decisions from this program are D20 and D23 to D24 in36+[`docs/design.md`](../design.md).37+38+## Files39+40+- `mods/worlds/<pack-id>.cjs` is the pack. It must load with `loadMod` (see41+ [modding.md](../modding.md)).42+- `docs/worlds/<pack-id>.md` is the world bible: the theme, the mechanic, and43+ a level table (id, what it teaches, solver length, flags), plus design notes.44+- `docs/worlds/friction.md` is the **shared friction log**. It's append-only;45+ use its entry template.46+- **Do not edit** `src/`, `tests/`, `index.html`, `openspec/`, or other47+ people's files. The engine team owns them. Scratch experiments go in `/tmp`.48+49+## Tools50+51+- `node tools/solve.mjs mods/worlds/<pack-id>.cjs` runs a breadth-first solve52+ on the real engine. It prints the shortest solution and flags **BYPASS** (the53+ level is winnable without moving any word) and **TRIVIAL** (under 6 steps).54+ Use `--level <id>` for one level and `--max <n>` to raise the search cap.55+- `node tools/solve.mjs core` solves the built-in levels.56+57+## Quality bar for a world58+59+1. **6 to 8 levels**, ordered to teach, then twist, then combine. Mark each60+ level's `category` as `required`, `optional`, or `challenge`, and fill in61+ `introduces`.62+2. **Every level solves.** The solver output goes in the world bible.63+3. **No accidental bypass.** A BYPASS is allowed only when it's the point of64+ the level (for example "you don't need to touch anything"), and the bible65+ has to say so.66+4. **One idea per level.** The first level of an idea is small and safe; a67+ later level makes the player *use* it. Include at least one `challenge`68+ level of 25 steps or more with a non-obvious rule edit.69+5. **Board size:** at most 14ร10 (it has to read on a phone), and keep blank70+ rows between stacked sentences where possible (design's spacing rule).71+6. **Titles and hints follow the design copy deck:** sentence-case titles, a72+ hint that nudges without giving the answer, no `ยท`, and no "garden".73+7. **Palette:** every custom noun gets an emoji that reads at 60% of a cell on74+ a near-black board. Directional actors can declare facing:75+ `{ glyph, faces: 'left' }`.76+77+## Friction: what to log78+79+Log **anything** that made authoring or playing harder than it should be.80+That includes coordinates you had to count by hand, missing helpers,81+validation messages that didn't help, vocabulary you needed but didn't have,82+presentation that would confuse a player, and engine behaviour that surprised83+you. Log it the moment you hit it, including small things. Every friction84+entry needs a proposed fix and a suggested owner (`engine`, `design`, or85+`content`).@@ -0,0 +1,449 @@
1+# Clockwork: custom property contract2+3+Owner: Clockwork world-builder. Audience: the engine team, then design.4+Grounded in `src/engine.js` and `src/mods.js` as of commit `78137b1` plus the5+uncommitted automata work on 09-23.6+7+Clockwork proves a mod can add custom rules. It uses two properties:8+9+| Property | Kind | Status |10+| --- | --- | --- |11+| `TICK` | Autonomous: things repeat a step pattern, one beat per turn | **Shipped** as declarative `level.automata`. All 6 Clockwork levels use it and pass the real solver. |12+| `OPEN` / `SHUT` | Contact: an `OPEN` and a `SHUT` thing in one cell destroy each other | **Proposed** as declarative `level.pairs` (below). Prototype only, 3 draft levels. |13+14+Both are **data, not code**. An earlier JS-handler draft of this API was15+dropped on the engine team's direction. Its findings are recorded in16+[Rejected: JS handlers](#rejected-js-handlers), because they explain why data17+is the right call.18+19+## Ask for the engine team20+21+Most of the first round landed: `subject` is optional (the rule word carries22+the pattern), `wait()` is exported, the solver restores phase and tries `W`,23+and a thing the player pushed still takes its beat. The remaining asks, in24+priority order:25+26+1. **Stop the silent noun trap.** A word the parser doesn't know is a noun. If a27+ level says `DOOR IS SHUT` without declaring `SHUT`, every door silently28+ turns into an unglyphed `SHUT` thing and stops being `STOP`. That's what29+ happens on the shipped engine today to the draft level `key-and-door`,30+ which becomes free to walk through. Ask: at load time, warn about any31+ predicate word that isn't a declared property and isn't a noun that32+ appears in the pack's objects, palette, or vocabulary. Also reject an33+ object whose noun equals a declared property word. `validateLevel` accepts34+ `{ noun: 'TICK' }` today.35+2. **Draw declared properties as property tiles.** `src/render/board.js`36+ hard-codes `TICK` into its `PROPERTIES` set. A pack that names its37+ property `WIND` or `SPIN` gets a noun-style tile. Derive the set from38+ `level.automata[].property` (and `level.pairs[].words` later).39+3. **Let the player learn the word.** Accept an optional pack-level glossary,40+ `properties: { TICK: { accent: 'push', describe: 'Repeats its steps, one for each of your turns.' } }`.41+ `accent` is a property colour token. `describe` is one sentence of at most42+ 80 characters, shown the first time the tile appears and in the ledger. The43+ shipped loader already passes this field through untouched. Clockwork44+ declares it.45+4. **Keep the loader and engine in step.** The engine accepts `'U'`/`'W'`46+ shorthand steps, but `mods.js` rejects them. Accept both in both places,47+ or neither.48+5. **Reconcile the OpenSpec change.**49+ `openspec/changes/add-rule-driven-automata/specs/.../spec.md` still50+ describes a different model (one automaton, step IDs, property51+ additions and removals) from the code (many automata, move and wait52+ steps). Clockwork follows the code.53+6. **Make the solver usable on automata levels.** At the default `--max 300000`,54+ `mouse-hole` and `escapement` return `LIMIT`. They need 3 to 4 million55+ expansions and about 3 minutes each, mostly in the BYPASS pass. A compact56+ state key (only moved or changed objects, plus phase) and a per-level57+ `solverMax` hint would help.58+7. **Ship `level.pairs`** (the `OPEN`/`SHUT` contract below), or tell us it's59+ out of scope so Clockwork can retire it.60+8. **Feature flags instead of silent ignore.** `automata` shipped without a61+ schema bump. An older build ignores the field, so the level loads with its62+ property word as a noun. Proposal: `manifest.features: ['automata']`63+ (later `'pairs'`). A loader that doesn't know a listed feature rejects the64+ pack with the player line "This pack needs a newer Miau." `manifest.engine`65+ stays `1`.66+67+**Complete proof example.** This is Clockwork level 1 exactly as the pack68+builds it. The shipped `tools/solve.mjs` solves it in 11 steps69+(`DRRRRRRRRRR`).70+71+```js72+const L = { action: 'move', dx: -1, dy: 0 };73+const R = { action: 'move', dx: 1, dy: 0 };74+const w = (text, x, y) => ({ kind: 'word', text, x, y });75+const o = (noun, x, y) => ({ kind: 'object', noun, x, y });76+const row = (noun, y, xs) => xs.map(x => o(noun, x, y));77+const all = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];78+79+module.exports = {80+ manifest: { id: 'clockwork-proof', name: 'Clockwork proof', version: '1.0.0', engine: 1 },81+ palette: { MIAU: { glyph: '๐', faces: 'left' }, MOUSE: { glyph: '๐', faces: 'left' }, WALL: '๐งฑ', BELL: '๐' },82+ levels: [{83+ id: 'mouse-keeps-time',84+ title: 'The mouse keeps time',85+ hint: 'The mouse takes one step for every step you take.',86+ width: 11,87+ height: 7,88+ automata: [{ id: 'mouse-shuttle', subject: 'MOUSE', property: 'TICK', phase: 0, pattern: [L, L, L, L, L, R, R, R, R, R] }],89+ objects: [90+ w('MIAU', 0, 0), w('IS', 1, 0), w('YOU', 2, 0),91+ w('WALL', 4, 0), w('IS', 5, 0), w('STOP', 6, 0),92+ w('MOUSE', 8, 0), w('IS', 9, 0), w('TICK', 10, 0),93+ ...row('WALL', 2, all),94+ o('WALL', 0, 3), w('BELL', 1, 3), w('IS', 2, 3), w('WIN', 5, 3), o('MOUSE', 9, 3), o('WALL', 10, 3),95+ ...row('WALL', 4, all),96+ o('MIAU', 0, 5),97+ o('BELL', 10, 6),98+ ],99+ }],100+};101+```102+103+Expected result: the mouse walks left and pushes `WIN` into `BELL IS _` on104+beat 5. On its next left run it waits against the full sentence. The cat105+reaches the bell in 11 steps. If `TICK` weren't declared, `MOUSE IS TICK`106+would turn the mouse into a `TICK` thing at reset.107+108+## TICK: the automata contract Clockwork relies on109+110+This section documents the shipped behaviour Clockwork depends on. A change to111+any line here would break a level.112+113+**Declaration** (per level, data only):114+115+```js116+automata: [{117+ id: 'mouse-shuttle', // stable, unique in the level; phase is keyed by it118+ subject: 'MOUSE', // optional; omit it and every noun that IS TICK follows the pattern119+ property: 'TICK', // custom property word, never a core word120+ phase: 0, // optional starting index121+ pattern: [ // 1 to 32 steps122+ { action: 'move', dx: -1, dy: 0 },123+ { action: 'wait' },124+ ],125+}]126+```127+128+**Semantics:**129+130+- **Parsing.** Every declared `property` joins the level's property set, so131+ `MOUSE IS TICK` is a property rule and never a transformation.132+- **Activation.** A record is active when some rule `X IS <property>` holds,133+ where `X` is its `subject` if it has one. Its actors are all things that134+ have the property and match the subject.135+- **Beat.** Each accepted player turn is one beat, whether it's a move or a136+ `wait()`. A blocked move isn't a turn, so no beat happens.137+- **Phase.** Each record has its own phase. It advances by one only on a beat138+ where the record is active and has at least one actor, so a paused clock139+ resumes where it stopped. Records with different subjects but the same140+ word keep separate phases.141+- **Action.** A move uses the player's push planner: it pushes `PUSH` things142+ and word tiles, `STOP` blocks it, and it walks onto anything else (including143+ `YOU`). A blocked actor waits in place and the phase still advances. Actors144+ move front-first along the step direction, then by id.145+- **Turn order.** Player move, then parse and transform, then all automata146+ (records by id), then parse and transform, then one contact pass (`SINK`,147+ then `DEFEAT`, then `WIN`).148+- **Undo, replay, and solver.** Phase is in `state.automata`, so snapshots,149+ `restore()`, and the solver's state key include it.150+151+**Consequences for level design** (all found while building Clockwork):152+153+- **Contacts run once, at the end of the turn.** A `YOU` that is also `TICK`154+ moves twice per turn and never stops on the cell in between, so it can155+ step over a `DEFEAT` line (`double-time`). A transform caused by an156+ automaton's push lands before contacts (`escapement`: the cat steps onto a157+ cog on the very turn the cogs become bells).158+- **A moving `DEFEAT` or `STOP` can't guard a corridor.** The player can swap159+ cells with it head-on, or let it walk onto them. A timing puzzle built on a160+ patrolling hazard always has a timing bypass. Clockwork uses static hazards161+ and moving helpers instead.162+- **Rules move with the word.** With no `subject`, pushing `TICK` from163+ `MOUSE IS TICK` to `BELL IS TICK` hands over the pattern and its phase164+ (`ring-for-service`).165+166+## OPEN and SHUT: proposed `level.pairs`167+168+This is the next extension in the same declarative style. It adds no code169+hooks, no new state, and no new turn phase.170+171+```js172+pairs: [{ words: ['OPEN', 'SHUT'], effect: 'destroy-both', enter: true }]173+```174+175+| Field | Rule |176+| --- | --- |177+| `words` | Two distinct custom property words. They join the level's property set. Neither word can be a core word, an automaton property, or a noun used by the level's objects. |178+| `effect` | Only `'destroy-both'` in this slice. |179+| `enter` | Optional, default `false`. When `true`, a mover with one word may enter a cell held by a thing with the other word even if that thing is `STOP` or `PUSH`. The occupant is neither pushed nor blocking. This is what lets a key go into a locked door. |180+| Limit | At most 4 pairs per level. |181+182+**Resolution.** At the start of every contact pass, before `SINK`, each cell183+is checked for each pair in declaration order. Things with the first word and184+things with the second word are matched one for one, in entity id order. A185+thing never pairs with itself. Every matched thing is destroyed, and the186+settled event's `destroyed` entries carry `cause: 'open'` (the first word,187+lowercased). Unmatched extras survive.188+189+**Ordering.** Pairs resolve after automata, because they're part of the single190+contact pass. An `OPEN` mouse that walks into a `SHUT` door on its beat191+opens it that same turn (`let-the-mouse-in`).192+193+**Undo, replay, and solver.** Pairs are stateless. Destruction is part of the194+object list, so snapshots, `restore()`, and the solver work unchanged. The195+prototype check replays `key-and-door` twice with identical states and events,196+and Undo of the opening turn restores both the key and the door.197+198+**Validation and sharing.** Pairs are plain data, so a shared link carries199+them the same way it carries automata. `mods.js` validates the fields above200+with player-readable messages.201+202+**Presentation.** Design needs a destruction cue for `cause: 'open'`. It's203+different from sink and defeat: a click, then both sprites drop away. The204+glossary (ask 3) explains both tiles.205+206+## Security and determinism207+208+- Automata and pairs are data. They run inside the host's own turn code, so209+ they add no execution surface, and a shared link stays data-only.210+- Everything is ordered by position projection and entity id. There's no211+ randomness, no wall clock, and no reliance on object key order.212+- Limits: 32 automata of 32 steps, 4 pairs, and one beat per turn, so there's213+ no cascade to cap.214+215+## Rejected: JS handlers216+217+The first draft let a pack register `properties: { MOVE: { phase, run(view, self) } }`218+with pure handlers that returned queued commands (`move`, `turn`, `destroy`)219+and an `admits(view, mover, blocker)` predicate. It worked in a prototype220+(kept at `/tmp/clockwork-proto/attic/engine-handlers.js`, about 270 changed221+lines), including rollback of a throwing handler. It was dropped for four222+reasons:223+224+1. `loadMod` clones the export with `structuredClone`, which throws225+ `run(){} could not be cloned.` on any function.226+2. The pack library, URL links, and exports store data only, so a pack with227+ handlers can't be persisted or shared without sending code.228+3. Handlers needed engine-owned facing state (`dir`) that doesn't exist today.229+ `reset()` silently drops unknown object fields such as `dir`.230+4. Error isolation needs whole-turn rollback and a fault channel through every231+ caller. Data can't throw.232+233+## Appendix A: prototype234+235+Location: `/tmp/clockwork-proto/`.236+237+- `base/`: snapshot of `src/engine.js` and `src/mods.js` (09-23, with the238+ automata slice).239+- `src/`: the snapshot plus the `pairs` patch. Apply it with240+ `python3 patch-engine.py && python3 patch-mods.py`, which re-applies onto a241+ fresh `base/`.242+- `tools/solve.mjs`: a copy of the repo solver that imports `../src`.243+- `verify.mjs`: the same BFS with an extra **HANDS-OFF** flag (won without the244+ player pushing a word), usable against the shipped engine (`--engine real`)245+ or the prototype (`--engine proto`).246+- `clockwork-pairs.cjs`: the three draft `OPEN`/`SHUT` levels.247+- `checks.mjs`: the replay, Undo, and noun-trap evidence quoted above.248+- `replay.mjs`: prints the board after a move string.249+250+Engine diff: +55 and -2 lines. Loader diff: +40 lines.251+252+### Engine diff (relative to `src/engine.js`)253+254+```diff255+--- a/src/engine.js256++++ b/src/engine.js257+@@ -6,6 +6,9 @@258+ const key = (x, y) => `${x},${y}`;259+ const directions = [[1, 0], [0, 1]];260+ const TOKEN_RE = /^[A-Z][A-Z0-9_-]*$/;261++/** Clockwork proposal: declarative paired contact effects. */262++export const PAIR_EFFECTS = Object.freeze(['destroy-both']);263++export const MAX_LEVEL_PAIRS = 4;264+ 265+ function validateLevel(level) {266+ if (!level || typeof level !== 'object') throw new TypeError('level must be an object');267+@@ -50,6 +53,22 @@268+ if (system.phase !== undefined && (!Number.isInteger(system.phase) || system.phase < 0)) throw new TypeError(`${context}: automata ${system.id} phase must be a non-negative integer`);269+ });270+ }271++ if (level.pairs !== undefined) {272++ if (!Array.isArray(level.pairs) || level.pairs.length > MAX_LEVEL_PAIRS) throw new TypeError(`${context}: pairs must be an array of at most ${MAX_LEVEL_PAIRS}`);273++ const pairWords = new Set();274++ level.pairs.forEach((pair, i) => {275++ if (!pair || typeof pair !== 'object' || !Array.isArray(pair.words) || pair.words.length !== 2) throw new TypeError(`${context}: pairs[${i}] needs two property words`);276++ for (const word of pair.words) {277++ const upper = typeof word === 'string' ? word.toUpperCase() : '';278++ if (!TOKEN_RE.test(upper) || ['IS', 'AND', ...CORE_PROPERTIES].includes(upper) || pairWords.has(upper)) throw new TypeError(`${context}: pairs[${i}] words must be distinct custom properties`);279++ if ((level.automata || []).some(system => String(system.property).toUpperCase() === upper)) throw new TypeError(`${context}: pairs[${i}] word ${upper} is already an automaton property`);280++ if (level.objects.some(o => (o.kind || (o.text != null ? 'word' : 'object')) === 'object' && String(o.noun).toUpperCase() === upper)) throw new TypeError(`${context}: pairs[${i}] word ${upper} is also used as a noun`);281++ pairWords.add(upper);282++ }283++ if (!PAIR_EFFECTS.includes(pair.effect)) throw new TypeError(`${context}: pairs[${i}] effect must be ${PAIR_EFFECTS.join(' or ')}`);284++ if (pair.enter !== undefined && typeof pair.enter !== 'boolean') throw new TypeError(`${context}: pairs[${i}] enter must be a boolean`);285++ });286++ }287+ return clone(level);288+ }289+ 290+@@ -71,7 +90,8 @@291+ return { ...action };292+ }),293+ }));294+- const dynamicProperties = new Set(automataDefs.map(system => system.property));295++ const pairDefs = (original.pairs || []).map(pair => ({ words: pair.words.map(word => word.toUpperCase()), effect: pair.effect, enter: pair.enter === true }));296++ const dynamicProperties = new Set([...automataDefs.map(system => system.property), ...pairDefs.flatMap(pair => pair.words)]);297+ const semanticProperties = new Set([...CORE_PROPERTIES, ...dynamicProperties]);298+ let state;299+ let history = [];300+@@ -179,11 +199,43 @@301+ for (const r of state.rules) if (!semanticProperties.has(r.predicate) && r.subject !== r.predicate && !changes.has(r.subject)) changes.set(r.subject, r.predicate);302+ for (const o of state.objects) if (o.kind === 'object' && changes.has(o.noun)) o.noun = changes.get(o.noun);303+ }304++ // Pairs resolve before SINK, DEFEAT, and WIN: in each cell, things with the305++ // first word and things with the second word destroy each other one for one,306++ // matched in entity id order. A thing never pairs with itself.307++ function resolvePairs() {308++ const causes = new Map();309++ if (!pairDefs.length) return causes;310++ const byId = (a, b) => String(a.id).localeCompare(String(b.id));311++ for (const pair of pairDefs) {312++ const cells = new Map();313++ for (const o of state.objects) { const k = key(o.x, o.y); if (!cells.has(k)) cells.set(k, []); cells.get(k).push(o); }314++ const removed = new Set();315++ for (const occupants of cells.values()) {316++ if (occupants.length < 2) continue;317++ const firsts = occupants.filter(o => properties(o).has(pair.words[0])).sort(byId);318++ const seconds = occupants.filter(o => properties(o).has(pair.words[1])).sort(byId);319++ for (const a of firsts) {320++ if (removed.has(a.id)) continue;321++ const b = seconds.find(candidate => candidate.id !== a.id && !removed.has(candidate.id));322++ if (!b) continue;323++ for (const o of [a, b]) { removed.add(o.id); causes.set(o.id, { cause: pair.words[0].toLowerCase(), at: { x: o.x, y: o.y }, noun: o.noun, text: o.text }); }324++ }325++ }326++ if (removed.size) state.objects = state.objects.filter(o => !removed.has(o.id));327++ }328++ return causes;329++ }330++ // A mover may enter a cell held by its pair partner even if that partner is STOP or PUSH.331++ function enters(mover, blocker) {332++ if (!pairDefs.length) return false;333++ const a = properties(mover), b = properties(blocker);334++ return pairDefs.some(pair => pair.enter && ((a.has(pair.words[0]) && b.has(pair.words[1])) || (a.has(pair.words[1]) && b.has(pair.words[0]))));335++ }336+ function resolveContacts(evaluateWin = true) {337++ const causes = resolvePairs();338+ const cells = new Map();339+ for (const o of state.objects) { const k = key(o.x, o.y); if (!cells.has(k)) cells.set(k, []); cells.get(k).push(o); }340+ const removed = new Set();341+- const causes = new Map();342+ for (const occupants of cells.values()) {343+ // A single entity can carry both YOU and WIN after a rule changes. It344+ // wins without needing a second occupant in its cell.345+@@ -213,6 +265,7 @@346+ const collisions = state.objects.filter(other => other.id !== o.id && other.x === tx && other.y === ty && !planned.has(other.id));347+ for (const other of collisions) {348+ const traits = properties(other);349++ if (enters(o, other)) continue;350+ if (traits.has('STOP')) return { ok: false, blocked: { id: o.id, dir: [dx, dy], blockerId: other.id, reason: 'stop' } };351+ if (traits.has('PUSH')) {352+ const result = planAutomatonMove(other, dx, dy, planned, plannedPushers, o.id, order);353+```354+355+### Loader diff (relative to `src/mods.js`)356+357+```diff358+--- a/src/mods.js359++++ b/src/mods.js360+@@ -11,6 +11,8 @@361+ export const MAX_MOD_LEVELS = 64;362+ export const MAX_LEVEL_OBJECTS = 4096;363+ export const MAX_LEVEL_AUTOMATA = 32;364++export const MAX_LEVEL_PAIRS = 4;365++const PROPERTY_ACCENTS = new Set(['you', 'push', 'stop', 'win', 'sink', 'defeat']);366+ export const MAX_AUTOMATON_PATTERN = 32;367+ 368+ const ID_RE = /^[a-z][a-z0-9._-]{1,63}$/;369+@@ -160,6 +162,43 @@370+ }371+ });372+ }373++ }374++}375++376++// Clockwork proposal: level.pairs and the pack-level properties glossary.377++function validatePairs(level, path, errors) {378++ if (level.pairs === undefined) return;379++ if (!Array.isArray(level.pairs) || level.pairs.length > MAX_LEVEL_PAIRS) { errors.push(issue(`${path}.pairs`, `must be an array of at most ${MAX_LEVEL_PAIRS} pairs`)); return; }380++ const automatonWords = new Set((Array.isArray(level.automata) ? level.automata : []).map(a => String(a?.property).toUpperCase()));381++ const nouns = new Set((Array.isArray(level.objects) ? level.objects : []).filter(o => o?.kind !== 'word' && o?.text === undefined).map(o => String(o?.noun).toUpperCase()));382++ const seen = new Set();383++ level.pairs.forEach((pair, i) => {384++ const pairPath = `${path}.pairs[${i}]`;385++ if (!isRecord(pair)) { errors.push(issue(pairPath, 'must be an object')); return; }386++ for (const key of Object.keys(pair)) if (!['words', 'effect', 'enter'].includes(key)) errors.push(issue(`${pairPath}.${key}`, 'unsupported field'));387++ if (!Array.isArray(pair.words) || pair.words.length !== 2) errors.push(issue(`${pairPath}.words`, 'must name exactly two property words'));388++ else pair.words.forEach((word, j) => {389++ const upper = typeof word === 'string' ? word.toUpperCase() : word;390++ if (!token(upper) || RESERVED.has(upper)) errors.push(issue(`${pairPath}.words[${j}]`, 'must be a new uppercase property word'));391++ else if (seen.has(upper) || automatonWords.has(upper)) errors.push(issue(`${pairPath}.words[${j}]`, `${upper} is already declared in this level`));392++ else if (nouns.has(upper)) errors.push(issue(`${pairPath}.words[${j}]`, `${upper} is also used as a noun in this level`));393++ seen.add(upper);394++ });395++ if (pair.effect !== 'destroy-both') errors.push(issue(`${pairPath}.effect`, "must be 'destroy-both'"));396++ if (pair.enter !== undefined && typeof pair.enter !== 'boolean') errors.push(issue(`${pairPath}.enter`, 'must be true or false'));397++ });398++}399++400++function validateGlossary(mod, errors) {401++ if (mod.properties === undefined) return;402++ if (!isRecord(mod.properties)) { errors.push(issue('properties', 'must map property words to { accent, describe }')); return; }403++ for (const [word, entry] of Object.entries(mod.properties)) {404++ const path = `properties.${word}`;405++ if (!token(word) || RESERVED.has(word)) errors.push(issue(path, 'key must be a new uppercase property word'));406++ if (!isRecord(entry)) { errors.push(issue(path, 'must be { accent, describe }')); continue; }407++ for (const key of Object.keys(entry)) if (!['accent', 'describe'].includes(key)) errors.push(issue(`${path}.${key}`, 'unsupported field'));408++ if (!PROPERTY_ACCENTS.has(entry.accent)) errors.push(issue(`${path}.accent`, `must be one of ${[...PROPERTY_ACCENTS].join(', ')}`));409++ if (typeof entry.describe !== 'string' || entry.describe.trim() === '' || entry.describe.length > 80) errors.push(issue(`${path}.describe`, 'must be one sentence of at most 80 characters'));410+ }411+ }412+ 413+@@ -182,6 +221,7 @@414+ const levelIds = new Set();415+ mod.levels.forEach((level, index) => {416+ validateLevel(level, index, errors);417++ if (isRecord(level)) validatePairs(level, `levels[${index}]`, errors);418+ if (typeof level?.id === 'string' && LEVEL_ID_RE.test(level.id)) {419+ if (levelIds.has(level.id)) errors.push(issue(`levels[${index}].id`, `duplicates another level id (${level.id})`));420+ else levelIds.add(level.id);421+@@ -189,6 +229,7 @@422+ });423+ }424+ validatePalette(mod.palette, errors);425++ validateGlossary(mod, errors);426+ if (mod.vocabulary !== undefined) {427+ if (!Array.isArray(mod.vocabulary)) errors.push(issue('vocabulary', 'must be an array of uppercase token strings'));428+ else mod.vocabulary.forEach((word, index) => {429+@@ -208,6 +249,7 @@430+ ...(typeof automaton.subject === 'string' ? { subject: automaton.subject.toUpperCase() } : {}),431+ ...(typeof automaton.property === 'string' ? { property: automaton.property.toUpperCase() } : {}),432+ })) } : {}),433++ ...(Array.isArray(level.pairs) ? { pairs: level.pairs.map(pair => ({ ...pair, words: pair.words.map(word => word.toUpperCase()) })) } : {}),434+ objects: level.objects.map(object => ({435+ ...object,436+ kind: object.kind || (object.text !== undefined ? 'word' : 'object'),437+```438+439+## Appendix B: draft pair levels (prototype solver)440+441+| id | title | steps | flags | idea |442+| --- | --- | --- | --- | --- |443+| `key-and-door` | Every door has a key | 17 | none | Build `KEY IS PUSH AND OPEN`, then push the key into the locked door. |444+| `any-wall-will-do` | Any wall will do | 32 | none | No door exists. `WALL IS STOP AND SHUT` turns every wall into a lock. Aim the one key at the single-thickness side of the bell's box. |445+| `let-the-mouse-in` | Let the mouse in | 13 | none | The cat is sealed in. `MOUSE IS OPEN` lets the pacing mouse open the pen door from outside. |446+447+Command: `node /tmp/clockwork-proto/verify.mjs /tmp/clockwork-proto/clockwork-pairs.cjs --engine proto --max 4000000`.448+On the shipped engine the same file loads without an error, and every level is449+broken. `SHUT` and `OPEN` become nouns, and doors turn into `SHUT` things.@@ -0,0 +1,120 @@
1+# Clockwork (world bible)2+3+Pack: [`mods/worlds/clockwork.cjs`](../../mods/worlds/clockwork.cjs) (id4+`clockwork`, `manifest.engine: 1`). Contract and engine asks:5+[clockwork-api.md](clockwork-api.md).6+7+## Theme8+9+A clock tower workshop after hours. Wind-up mice pace their tracks, cogs line10+the floor, and a brass bell waits somewhere the cat can't quite reach. Nothing11+here is alive, but some things keep moving between your steps.12+13+World identity (`manifest.world`): glyph โ๏ธ, tagline "Some things keep walking14+after you stop.", accent `push`. The shipped loader accepts the field without15+validating it (see F-CW8).16+17+| Noun | Glyph | Facing | Role |18+| --- | --- | --- | --- |19+| MIAU | ๐ | left (built-in) | the player |20+| MOUSE | ๐ | left (profile) | the clockwork helper |21+| COG | โ๏ธ | none | floor hazard (`COG IS DEFEAT`) |22+| BELL | ๐ | none | the goal (`BELL IS WIN`) |23+| WALL | ๐งฑ | none | terrain |24+25+## The custom property: TICK26+27+`X IS TICK` makes every X repeat the level's step pattern, one beat per turn28+(a move or a wait). This is the engine's declarative `level.automata`. Every29+level declares its pattern as data. Levels 1, 2, and 5 bind the pattern to30+MOUSE with `subject`. Levels 3, 4, and 6 leave `subject` out, so whichever31+noun holds the `TICK` word follows the pattern. Moving the word hands the32+clockwork to something else.33+34+Rules players meet, in order:35+36+1. Clockwork moves on its own, one step per turn, and pushes words.37+2. It only runs while its sentence holds.38+3. The word carries the clockwork: `BELL IS TICK` makes the goal walk.39+4. You can be clockwork too, and a double step skips the cell in between.40+5. Clockwork combines with core properties: a `SINK` mouse takes a cog with it.41+6. Challenge: the clockwork delivers a noun, and the hazard becomes the goal.42+43+The world's second property (`OPEN` and `SHUT`, a paired contact effect) is a44+**proposal** (`level.pairs`, see clockwork-api.md). Its three draft levels run45+only on the prototype and aren't in the pack.46+47+## Levels48+49+Solver: `node --max-old-space-size=8000 tools/solve.mjs mods/worlds/clockwork.cjs --max 4000000`50+on the shipped engine. The full run takes about 4 minutes, and the default heap51+runs out of memory on an earlier, roomier layout of `ring-for-service`. HANDS-OFF comes from `/tmp/clockwork-proto/verify.mjs`: the52+level is winnable without the player pushing any word (F-CK9). No level is53+flagged BYPASS or TRIVIAL.54+55+| # | id | Title | Category | Teaches | Steps | Flags | Intended insight |56+| --- | --- | --- | --- | --- | --- | --- | --- |57+| 1 | `mouse-keeps-time` | The mouse keeps time | required | TICK exists; clockwork pushes words | 11 | HANDS-OFF (by design) | Just walk. The mouse delivers `WIN` into `BELL IS _` in a sealed lane on beat 5. |58+| 2 | `wind-it-up` | Wind it up | required | Writing `MOUSE IS TICK` | 18 | none | The mouse is idle until you push `TICK` into `MOUSE IS _`. Then it delivers `WIN`. |59+| 3 | `ring-for-service` | Ring for service | required | The word carries the pattern | 10 | none | The cat can't cross the cogs, but the bell can. Move `TICK` from the mouse to `BELL IS _` and the bell walks to you. |60+| 4 | `double-time` | Double time | required | `MIAU IS TICK`; end-of-turn contacts | 11 | none | The pattern is one step down. Make yourself `TICK`, stand above the cogs, and step down. You move twice and never stop on a cog. |61+| 5 | `mouse-hole` | Mouse hole | required | TICK with core `SINK` | 23 | none | Push `SINK` into `MOUSE IS TICK AND _`. The diving mouse sinks one cog and leaves a gap. |62+| 6 | `escapement` | Escapement | challenge | Delivering a noun; transformation | 29 | none | `WIN` is the bait: `COG IS WIN` still kills you, because `DEFEAT` beats `WIN`. Load `BELL` into the mouse's column instead, time it with the press, and `COG IS BELL` turns the hazard into the goal. |63+64+Shortest solutions (real solver):65+66+- `mouse-keeps-time`: `DRRRRRRRRRR`67+- `wind-it-up`: `RRRRURULLDDRRRRRRR`68+- `ring-for-service`: `URRRDRRUDL`69+- `double-time`: `RRURULLLLDR`70+- `mouse-hole`: `URRRRRDRUUUUDDDDRDDDLLL`71+- `escapement`: `UUDUDUDUDURRRRRRRRRDRDLLLUDLD` (the opening bobs are waits for the press to cycle; `W` works too)72+73+All boards fit in 14ร10, the largest being 11ร10.74+75+### Bypass notes76+77+- `mouse-keeps-time` is hands-off **on purpose**. It's the "just watch" level78+ that shows a word moving with nobody pushing it. It isn't flagged BYPASS,79+ because the mouse moves a word.80+- Every other level needs the player to push a word. Both the frozen-words81+ search (BYPASS) and the player-pushes-nothing search (HANDS-OFF) come back82+ unsolvable.83+84+## Design notes85+86+- **Locking sentences.** No word tile can be fixed, so every rule the player87+ must not break sits in a corner or fills an edge row (for example,88+ `mouse-hole` row 0 is `MIAU IS YOU MOUSE IS TICK AND _ BELL IS WIN`, with89+ the slot as the only gap). This costs rows and reads as clutter. Same90+ finding as F-CW2 and F-XW2.91+- **No patrol guards.** Contacts resolve once, at the end of the turn, so a92+ moving `DEFEAT` can be swapped through head-on (F-CK6). The world uses93+ static cogs as the hazard and moving helpers as the tool. A guard level94+ (`stop-the-clock`) was cut after three layouts.95+- **Spacing.** Stacked horizontal sentences keep a blank row between them. The96+ only vertical sentences are deliberate crossings (`BELL / IS / _` under97+ `BELL IS WIN`, `MIAU / IS / _` under `MIAU IS YOU`).98+- **The last-moment transform in `escapement`.** The shortest route steps onto99+ a cog on the same turn the mouse pushes `BELL` into place. That's legal,100+ because transforms run before contacts. A human will usually wait a turn101+ and then step.102+- **Solver cost.** `mouse-hole` and `escapement` need `--max 4000000` and103+ about 3 minutes each. At the default they return LIMIT (F-CK9, F-CW4).104+- **The engine changed three times during authoring.** It went from no105+ automata, to subject-bound automata with pushed things skipping their106+ beat, to the shipped model. Levels were re-verified after each change.107+108+## OPEN and SHUT (proposal, not in the pack)109+110+The draft levels are in `/tmp/clockwork-proto/clockwork-pairs.cjs`, verified111+on the prototype. See Appendix B of clockwork-api.md.112+113+| id | Title | Steps | Idea |114+| --- | --- | --- | --- |115+| `key-and-door` | Every door has a key | 17 | Build `KEY IS PUSH AND OPEN`, then push the key into the door. |116+| `any-wall-will-do` | Any wall will do | 32 | `WALL IS STOP AND SHUT`: any wall is a lock. Pick the thin side. |117+| `let-the-mouse-in` | Let the mouse in | 13 | Sealed in, you write `MOUSE IS OPEN`, and the pacing mouse opens your door. |118+119+If pairs ship, these slot in after level 2. The world would then teach120+`TICK`, then `OPEN`/`SHUT`, then a combination (`let-the-mouse-in`).@@ -0,0 +1,308 @@
1+# Crate Works (world bible)2+3+Pack: [`mods/worlds/crate-works.cjs`](../../mods/worlds/crate-works.cjs), id `crate-works`, 7 levels.4+5+World identity (declared in `manifest.world`; `loadMod` accepts the field today6+without validating it, see F-CW8):7+8+```js9+world: { glyph: '๐ฆ', tagline: 'Every crate goes somewhere. So does every word.', accent: 'push' }10+```11+12+## Theme13+14+A loading dock after hours. The cat works the night shift: crates have to be15+shoved out of doorways, gaps in the dock floor have to be filled before16+anything can cross, and the only way out is the truck. The rule tiles are17+treated as cargo too. They block corridors, they sink into gaps, and18+sometimes the word you need is sitting inside a rule you're still using.19+20+| Noun | Glyph | Role |21+| --- | --- | --- |22+| `MIAU` | ๐ (`faces: 'left'`, profile) | The player |23+| `CRATE` | ๐ฆ | Cargo. Usually `PUSH`, sometimes `YOU`, sometimes nothing |24+| `TRUCK` | ๐ | The goal (`WIN`). In one level it is also `PUSH` |25+| `GAP` | ๐ง | A hole in the dock floor (`SINK`) |26+| `WALL` | slab (the renderer ignores the palette glyph for `WALL`) | Racking. Always `STOP`, always sealed away from the player |27+28+The glyphs are plain strings except the cat. ๐ is drawn in profile too, but29+it only ever moves by being pushed, and pushing doesn't change facing, so it30+doesn't declare `faces`.31+32+## Mechanic33+34+Sokoban pushing, played through the core vocabulary only (`YOU PUSH STOP WIN35+SINK`, `AND`, and noun-to-noun transforms). Three ideas carry the world:36+37+1. **Gaps are Sokoban goals that eat the box.** Any pushable thing that enters38+ a `GAP IS SINK` cell disappears along with the gap and leaves floor behind.39+ That makes "push every box into a hole" levels possible, which is the40+ closest the core engine gets to real Sokoban.41+2. **Words are cargo.** Words are always `PUSH`, so they fill gaps too.42+ Spending a word removes its rule, so *when* you spend it matters.43+3. **The goal can move.** `TRUCK IS PUSH AND WIN` means you can never stand on44+ the truck. The only way to share a cell with it is to push it onto a second45+ `YOU` that is held still by a wall. That's the core-engine version of46+ "box on goal".47+48+## Levels49+50+Solver: `node tools/solve.mjs mods/worlds/crate-works.cjs --max 2000000`51+(all 7 SOLVED, no BYPASS, no TRIVIAL). Cross-checked with a compact52+re-implementation of the core rules (differentially tested against53+`src/engine.js`) that also verifies the key rule is *necessary*: forbidding54+`CRATE IS YOU` makes `in-step` and `back-it-up` unsolvable, and forbidding55+`GAP IS CRATE` makes `night-shift` unsolvable.56+57+| # | id | Title | Category | Board | Teaches | Solver steps | Flags | Intended insight |58+| --- | --- | --- | --- | --- | --- | --- | --- | --- |59+| 1 | `crates-move` | Crates move | required | 13ร7 | `CRATE`, `TRUCK`, `PUSH` | 15 | none | The crate fills the only doorway. Push it through, then walk around it and push `WIN` down into `TRUCK IS _`. |60+| 2 | `fill-the-gap` | Fill the gap | required | 12ร7 | `GAP`, `SINK` | 17 | none | Every rule is on the far side of the gap line. Sink the crate to make a one-cell bridge, then carry `WIN` into `TRUCK IS _`. |61+| 3 | `spend-it-last` | Spend it last | required | 14ร5 | sinking words, order | 34 | none | The moat is two gaps deep. You have one crate and one loose word, `PUSH`, and it's holding up `CRATE IS PUSH`. Sink the crate first while it's still pushable, then spend `PUSH` on the second gap. Moving `PUSH` first strands the crate. |62+| 4 | `in-step` | In step | required | 11ร9 | `AND`, shared `YOU` | 31 | none | Build `CRATE AND MIAU IS YOU`. The crate climbs a zigzag lane to the truck while the cat moves in lockstep in its own room. The cat has to reposition by using moves the crate's walls absorb, and must never shove the sentence it's standing under. |63+| 5 | `load-order` | Load order | optional | 12ร7 | push order in a tight room | 32 | none | `WIN` starts in front of both crates, next to two gaps. Pushing straight sinks `WIN`. Park `WIN` aside, sink crate one, carry crate two over the first bridge into the second gap, then bring `WIN` across into the vertical `TRUCK IS _`. |64+| 6 | `back-it-up` | Back it up | optional | 13ร8 | pushable `WIN` | 20 | none | `TRUCK IS PUSH AND WIN` is sealed, so you can't stand on the truck. Put `CRATE` into `_ IS YOU`, then push the truck onto the crate while a wall holds the crate still. The shortest line walks the crate to the truck in lockstep; the longer Sokoban line pushes the truck into the crate's pocket first. Both use the same idea. |65+| 7 | `night-shift` | Night shift | challenge | 12ร9 | noun transform | 48 | none | The truck is behind a moat two gaps thick and there's no crate. The only loose word is `CRATE`, taken out of `CRATE IS PUSH`. Walk it through the racking (a word-Sokoban with one forced loop) into `GAP IS _`. Every gap becomes a crate, and since `CRATE IS PUSH` is broken those crates don't block you, so you walk straight through. Two non-obvious steps: turn the hazard into cargo, and dismantle the crate rule to get the word. |66+67+Solutions (from `tools/solve.mjs`, shortest):68+69+```70+crates-move RRRRRRRURRDDRDR71+fill-the-gap DRRRRRRDLDDRURRRR72+spend-it-last LLLLURULLLLDRRRRRRRRULLLLLLLLLLLLL73+in-step ULLUUUDDRUUDDDDRRRRUULDDLUURRUU74+load-order UURRRDULLDRLDDRULURRLDDRULURRRRD75+back-it-up RRURDDDRUULLDLLLLULL76+night-shift UUUURRRLLLDDDDRRRRUUUUUDLLLLUURRRRRRLLDDRRDDDRRR77+```78+79+Arc: 1 to 3 teach push, sink, and "words are cargo", in that order. 4 adds80+the second `YOU`. 5 is the pure push-order twist. 6 and 7 combine an earlier81+idea with an edit players don't expect: move the goal instead of the player,82+and turn the hazard into cargo.83+84+## Design notes85+86+- **Sealing the rules costs rows.** Nothing in the core can make a word87+ immovable, and walls only block if `WALL IS STOP` stays intact. Every level88+ keeps its fixed rules in a sealed "office": a word row with a full wall row89+ under it, or the top row packed with words and walls, since the board edge90+ stops upward pushes. Two early drafts were broken this way. `load-order` v191+ solved in 15 moves by pushing `STOP` up out of `WALL IS STOP` from the room92+ below. `spend-it-last` v1 used gaps as row-0 filler and was solved by93+ sinking a word into one and then pushing `GAP IS SINK` sideways into the94+ next (F-CW2).95+- **Pinning tricks used.** A word in row 0 can't be pushed up, and row 0 is96+ unreachable if every cell in it is a word or a wall. A word in row 1 with97+ walls to its left and right, under a row-0 word, is also pinned. That's how98+ `GAP / IS / _` in `night-shift` and `CRATE / IS / PUSH` in `spend-it-last`99+ give the player exactly one editable slot.100+- **Material counting.** Every pushable thing is gap filler, words included.101+ A moat has to be thicker than the number of loose words plus crates, or it102+ can be crossed by spending words. `night-shift` relies on this: one loose103+ word and a two-deep moat.104+- **Accidental "X IS WIN".** The first draft of `crates-move` was won by105+ pushing `WIN` onto `CRATE IS PUSH`, which made `CRATE IS WIN`. Any loose106+ `WIN` next to a sentence ending in a property is a shortcut risk.107+- **Lockstep reads as a puzzle.** In `in-step` the solver found a line where108+ the cat uses the crate's blocked moves to reposition. That's the intended109+ insight, and it isn't obvious from the board. The hint points at it110+ ("A wall holds one still while the other moves").111+- **Passable crates look identical to pushable ones.** After `night-shift`112+ turns the gaps into crates, those crates have no properties and can be113+ walked through, but nothing on the board shows it (F-CW13).114+115+## Goal feature spec: a real Sokoban goal116+117+**Problem.** Real Sokoban is won when *every* box is on a goal square. The core118+can only express "a YOU shares a cell with a WIN thing". Crate Works gets119+around this three ways: holes that eat boxes, moving the WIN thing onto a120+parked YOU, and making the crate the YOU. None of them expresses "all boxes121+home". Multi-box levels can't have a win state that depends on where the122+boxes are.123+124+**Proposal: a new core property `GOAL`.**125+126+- Syntax: an ordinary predicate, so `DOCK IS GOAL`, `GAP AND DOCK IS GOAL`, and127+ `DOCK IS GOAL AND STOP` all parse with the existing rule code. The tile uses128+ the `win` property colour family and is outlined, not filled, so it reads as129+ "a win condition" but not as `WIN`.130+- **Win check** (end of turn, in `resolveContacts`, after SINK/DEFEAT removal131+ and alongside the existing WIN check): the level is won if132+ 1. at least one `YOU` exists, and133+ 2. at least one `GOAL` thing exists, and134+ 3. every `GOAL` thing shares its cell with at least one *other* entity that135+ is currently `PUSH` (object or word).136+- `WIN` keeps working. Either condition wins.137+- Why "covered by a PUSH thing" and not "covered by a CRATE": it has no extra138+ parameters, and it composes with rules. Breaking `CRATE IS PUSH` stops139+ crates from counting. Words count, because words are always `PUSH`, so a140+ word parked on a dock is a legal cover. `BOX IS PUSH` lets any noun become141+ cargo.142+- Edge cases: the check never passes vacuously (condition 2). A `GOAL` thing143+ that is also `PUSH` needs a *second* PUSH entity on it. `SINK` removes144+ covers before the check runs. Transforms apply before the check.145+- Engine cost: no new state, because it's derived from positions and rules.146+ Undo, reset, sharing, and the solver hash need no changes. `settled()`147+ should report `goals: { covered, total }` so the HUD or ledger can show148+ `DOCK IS GOAL 2/3`.149+- Rejected alternative: a level-data predicate such as150+ `goal: { every: 'CRATE', on: 'DOCK' }`. It's invisible on the board, so151+ players can't read it or edit it. It needs a new metadata schema and a152+ separate UI. It also can't take part in rule puzzles, which is the point of153+ this game.154+155+Both drafts below were checked with a prototype of exactly this rule (a156+`GOAL` patch on the compact simulator in `/tmp/cw/sim-goal.mjs`). **Drafts157+only, not in the pack.** Legend additions: `+` DOCK, `*` CRATE on DOCK, word158+`D` DOCK, word `O` GOAL.159+160+### Draft G1: Pallet row (pure Sokoban, BYPASS on purpose)161+162+```js163+// DRAFT, needs the proposed GOAL property. Solves in 27: LUURRDULLDDRRRRUULLLRDDLLUU164+board([165+ 'MIY.WIS.CIP.',166+ '############',167+ '#+..#.######',168+ '#.$....#####',169+ '#.#$#..#DIO#', // DOCK IS GOAL, sealed in the racking170+ '#.@...+#####',171+ '############',172+], { id: 'pallet-row', title: 'Pallet row', category: 'optional',173+ hint: 'Every dock wants a crate. Corners are forever.', allowBypass: true });174+```175+176+The first real Sokoban proof. No word needs to move, so the solver reports177+BYPASS(27). That's the point of the level, which is why it declares178+`allowBypass`.179+180+### Draft G2: Short one crate (a word is cargo)181+182+```js183+// DRAFT, needs the proposed GOAL property. Solves in 36 (prototype):184+// RRRRRURDLLLUULDULLDLDRRRRRRDDRRUURUL185+board([186+ 'MIY.WIS.CIP.',187+ '############',188+ '#.....#....#',189+ '#.$.$.#.+..#',190+ '#.@...N.+..#', // three docks, two crates, one loose WIN word in the doorway191+ '#.....#.+..#',192+ '#######....#',193+ '############',194+ '...DIO......',195+], { id: 'short-one-crate', title: 'Short one crate', category: 'challenge',196+ hint: 'Three docks, two crates. Something else on this floor can be pushed.' });197+```198+199+There are two crates for three docks. The third cover is the loose `WIN` word,200+which does nothing as a rule here (there's no `X IS` in front of it) but is201+`PUSH` like every word. There's a push-order problem too: `WIN` starts in the202+doorway, so it goes through first and has to end up on the far dock without203+blocking the others.204+205+## Gates and switches (proposal)206+207+Provisional. The user may define other semantics. Three draft scenarios below208+were run on a prototype (`/tmp/cw/sim-gate.mjs`, a gate patch on the compact209+simulator) under the recommended default and under two alternatives210+(`ONLY_PUSH`, `CRUSH`). Results are quoted after each scenario.211+212+### Decisions213+214+| Question | Choice | Why (scenario) |215+| --- | --- | --- |216+| Entry or occupancy? | **Occupancy.** A plate is pressed while anything stands on it. | This is the Sokoban-goal question: "a crate sits on the plate" is a state you can see. Entry toggles need a hidden parity that the board doesn't show, and undo then has to restore a counter the player can't see. D1 (door stop) is only readable because the crate on the plate *is* the explanation. |217+| What can press? | **Anything:** any object or word, whatever its properties. | D2 (hand-off) is solvable only if a `YOU` crate with no `PUSH` can press. Under `ONLY_PUSH` it is UNSOLVED. "Something is on it" is also the one rule a player can check by looking. |218+| When do gates change? | **End of turn**, after movement, transforms, and contacts. Gates never change mid-turn. | With two `YOU`s (D2), same-turn evaluation would depend on the engine's YOU order (projection sort, then id). The cat would pass or bump depending on which YOU the engine happened to move first. End of turn is order-free, and matches how WIN/SINK already resolve. |219+| Something is in a closing gate? | **It stays open until the cell is empty** (a jam). No crushing. | D3 (jam) shows the alternative. Under `CRUSH` the one-crate corridor becomes a death trap (UNSOLVED), because the pusher always ends up in the gate as the plate empties. A jam is also a Sokoban tool: a crate works as a door stop. Design consequence: **keep plates at least 2 cells from their own gate**, or a lone YOU can step plate, gate, through and skip the puzzle. |220+| Closed gate behaviour | Acts as `STOP` for movement **regardless of rules**. An open gate has no properties. | Gates are mechanisms, not grammar. If gate state came from a rule (`GATE IS STOP`), breaking that rule would open every gate in the level. |221+| Linking | **By channel in level data.** `PLATE` and `GATE` objects carry `channel: 'amber' \| 'teal' \| โฆ`, taken from a small fixed palette. The renderer tints the plate ring and the gate bars with the channel colour, and the ledger lists `AMBER 1/1`. | Linking by noun (every PLATE opens every GATE) can't express two circuits. Linking by id is invisible to players. A colour is visible and needs no text. |222+| Rules or level data? | **Level data for the link and the state. Nouns stay nouns.** `PLATE` and `GATE` are ordinary objects, so `GATE IS CRATE` still transforms them (the role follows the noun and the channel stays on the entity). | This keeps the world's rule puzzles (transform a gate away, push a word onto a plate) and puts the circuit where the player can see it. A rule form like `PLATE IS SWITCH` / `GATE IS SHUT` can't say *which* plate opens *which* gate without a new grammar. |223+224+### Draft D1: Door stop (occupancy, anything presses)225+226+```js227+// DRAFT. '_' = PLATE (channel amber), '|' = GATE (channel amber).228+// Prototype: 15, LURRURDDURRRRRR (same under ONLY_PUSH and CRUSH).229+board([230+ 'MIY.WIS.CIP.',231+ '............',232+ 'GIK.........',233+ '############',234+ '#.....#TIN##',235+ '#.$.$.|.~.&#', // gate, then a gap, then the truck236+ '#.@...######',237+ '#..._.######', // plate two cells off the gate row238+ '############',239+], { id: 'door-stop' });240+```241+242+One crate has to sit on the plate and the other has to fill the gap behind243+the gate. The gate is open for as long as the first crate stays on the plate.244+BYPASS in the solver sense (no word moves). As shipped content it would need245+a rule edit, for example a missing `PUSH`.246+247+### Draft D2: Hand-off (end-of-turn timing, anything presses)248+249+```js250+// DRAFT. Prototype: 15, LUDLURRRRRURRRR. UNSOLVED under ONLY_PUSH.251+board([252+ 'WIS.TIN....',253+ '###########',254+ '#.......###',255+ '#..MIY..|.&', // the cat's corridor runs through the gate256+ '#CA.....###', // push A then C up: CRATE AND MIAU IS YOU257+ '#..@....###',258+ '###########',259+ '#$....._###', // the crate's lane ends on the plate, wall behind it260+ '###########',261+], { id: 'hand-off' });262+```263+264+After `CRATE AND MIAU IS YOU`, the crate walks right onto the plate while the265+cat walks its room. On the turn the crate lands, the gate is still closed266+during movement and opens at end of turn. The cat passes on the *next* `R`,267+and the crate stays on the plate because the wall at the end of its lane268+absorbs the move. If the crate isn't `PUSH` (it isn't here), only269+"anything presses" makes this work.270+271+### Draft D3: Jam (a gate never closes on an occupant)272+273+```js274+// DRAFT. Prototype: 7, RRRRRRR. UNSOLVED under CRUSH (the cat is crushed).275+board([276+ 'MIY.WIS.TIN',277+ '###########',278+ '#......#..#',279+ '#.@.$._|.&#', // plate right next to its own gate280+ '#......#..#',281+ '###########',282+], { id: 'jam' });283+```284+285+Push the crate over the plate and through the gate. The crate and then the286+cat keep the gate occupied as the plate empties, so it stays open. This also287+shows the authoring hazard: an adjacent plate makes the gate trivial.288+289+### Undo, reset, sharing, and solver requirements290+291+- **Snapshot.** Each gate entity stores `open: boolean` in `state.objects`.292+ A jam depends on history (the gate was open last turn and is occupied now),293+ so it can't be derived from positions alone. Plates hold no state. Undo294+ restores `open` from the snapshot like any other field. Reset recomputes295+ from the initial layout.296+- **Authoring.** Object records need an optional `channel` field, and the297+ `object()` helper needs a 5th argument or an options bag. Today `object()`298+ only takes `(noun, x, y, id)`, so the drafts spread the record by hand. The299+ loader should validate `channel` against the fixed palette and warn when a300+ channel has plates but no gates, or the reverse.301+- **Sharing.** `channel` must survive `exportPack`, `packLink`, and install.302+ It's plain data, with no code.303+- **Solver.** The state hash has to include `open` for every gate. Otherwise304+ two states that look identical but differ in a jam get merged and the305+ search gives wrong answers. The prototype adds one character per gate to306+ the key.307+- **Events.** `settled()` should report `gates: [{ id, from, to, cause:308+ 'pressed' | 'released' | 'jammed' }]` so juice and audio can react.@@ -0,0 +1,199 @@
1+# Crossword (world bible)2+3+- **Pack:** `mods/worlds/crossword.cjs` (pack id `crossword`, version 1.0.0)4+- **World identity** (`manifest.world`, accepted by `loadMod` today but not5+ validated): glyph `โ๏ธ`, tagline "Every word can be read across and down.",6+ accent `win`.7+- **Custom tiles:** none. No `palette`, and the only nouns are `MIAU`, `ROCK`,8+ `FLAG`, and `WALL`, which all have default glyphs in `src/main.js`.9+- **Custom rules:** none. Only `IS`, `AND`, `YOU`, `PUSH`, `STOP`, `WIN`, and10+ `DEFEAT` appear.11+12+## Theme13+14+A crossword, or a printer's type case. The board is a grid of words, and a15+word tile belongs to every line that passes through it: one `IS` can be read16+across and down, a noun can close one sentence and open the next, and two17+letters can share one square. Walls, where they appear, are the crossword's18+black squares.19+20+## Mechanic21+22+Pure grammar. There is nothing to learn in this world except how the parser23+reads the board:24+25+1. **Crossing.** A tile is read on both axes. One `IS` can serve a horizontal26+ and a vertical sentence at once.27+2. **AND on both sides.** A subject chain (`ROCK AND MIAU IS YOU`) and a28+ predicate chain (`ROCK IS DEFEAT AND PUSH`).29+3. **Pinned sentences.** A sentence flush against a board edge in the top or30+ bottom row can't be broken, but you can add words in front of it or after31+ it. This is the world's way of saying "you can't remove this, so extend it".32+4. **Stacks.** Two words in one cell travel together forever. The puzzle is to33+ find a sentence where both halves are welcome.34+5. **Nouns as answers.** A noun after `IS` transforms things, and the change35+ is permanent. That lets a single `IS` be spent twice.36+37+## Levels38+39+Verified on the W6 engine (Baba-style precedence, design decision D23) with40+the committed CLI: `node tools/solve.mjs mods/worlds/crossword.cjs` (default41+2M budget; `spend-it-twice` declares `solverBudget: 4000000` for margin). All42+7 levels are SOLVED, the run exits 0 (about 6 minutes), and there are no43+BYPASS, HANDS-OFF, TRIVIAL, WON-AT-START, SKIPPABLE, or LIMIT flags. No level44+sets `allowBypass`.45+46+`mustForm` is declared only where `--level <id> --forbid '<rule>'` returned47+UNSOLVED (an exhaustive proof), never LIMIT.48+49+| # | id | Title | Category | Teaches | Steps | Flags | mustForm (proof) | Intended insight |50+| --- | --- | --- | --- | --- | --- | --- | --- | --- |51+| 1 | `across-and-down` | Across and down | required | crossing (one `IS`, two sentences) | 16 | none | none: `FLAG IS WIN` is avoidable (see note) | `FLAG` sits over the `IS` of `MIAU IS YOU`. Slide `WIN` in under that `IS` **from the side** to read `FLAG IS WIN` down. Pushing it up from below lifts the `IS` out of your own sentence. |52+| 2 | `company` | Company | required | `AND` on the subject side, something else is `YOU` | 20 | none | `ROCK IS YOU` (UNSOLVED, 36,800 states) | `MIAU IS YOU` is pinned at the top right and the cat is penned. Push `ROCK` and `AND` up into the open cells in front: `ROCK AND MIAU IS YOU`. `ROCK MIAU IS YOU` without `AND` does nothing. |53+| 3 | `shove-the-danger` | Shove the danger | required | `AND` on the predicate side | 23 | none | `ROCK IS PUSH` (UNSOLVED, 121,760) | A column of rocks is `DEFEAT`, and the sentence is pinned in the corner. You can't remove `DEFEAT`, so append `AND PUSH` and shove a rock out of the column. |54+| 4 | `carry-the-pair` | Carry the pair | required | stacked words, DEFEAT on its own carrier | 24 | none | `ROCK IS DEFEAT` (UNSOLVED, 527,180) | The stacked `YOU+DEFEAT` can't be split, and a YOU that is also DEFEAT destroys itself. On MIAU the pair is suicide. Build `ROCK / IS / YOU+DEFEAT` down from the pinned `ROCK IS STOP`: every rock in the ring around the flag destroys itself, and you walk in. |55+| 5 | `pass-it-on` | Pass it on | required | noun as predicate, transformation | 24 | none | `ROCK IS FLAG` (UNSOLVED, 335,688) | The flag is sealed behind walls. Insert `ROCK IS` in front of the pinned `FLAG IS WIN` to read `ROCK IS FLAG IS WIN`. The one `FLAG` word is the answer to the first clue and the start of the second, and the rock next to you becomes a winning flag. |56+| 6 | `down-to-miau` | Down to MIAU | optional | a noun at a crossing (predicate down, subject across) | 19 | none | `ROCK IS MIAU` (UNSOLVED, 202,232) | Build `ROCK / IS / MIAU` down onto the pinned `MIAU IS YOU`. The rock in the sealed room becomes a second cat. A black square (wall) above `YOU` blocks the shorter `ROCK IS YOU` down. |57+| 7 | `spend-it-twice` | Spend it twice | challenge | sequencing, permanence of transformation | 35 | none | `WALL IS ROCK` (UNSOLVED, 1,905,020) | There's one free `IS`, and `WIN` and the flag are walled in. Build `WALL IS ROCK` down from the pinned `WALL`: every wall turns into a passable rock and stays one. Then take the **same** `IS` for a win rule. The solver found `ROCK IS WIN` across, which also turns the flag into a rock on the way. `FLAG IS WIN` down from the pinned `FLAG` works too. `WALL IS FLAG` is impossible because the only `FLAG` word is pinned as a subject. |58+59+**Why level 1 has no `mustForm`.** `--forbid 'FLAG IS WIN'` solves in 2660+steps: push `IS` down so `FLAG IS YOU` reads down (the cat stops being YOU,61+and the flag becomes the player), then build `MIAU IS WIN` across and walk the62+flag onto the cat. That's still a crossing route, so it teaches the same63+lesson, and I left it in.64+65+**Rework for W6 (2026-09-23).**66+- `carry-the-pair` originally had `FLAG IS WIN+DEFEAT` and a self-win on MIAU.67+ Under W6, a YOU that is also DEFEAT is destroyed, so the old route kills the68+ cat. The stack is now `YOU+DEFEAT`, and the lesson is its opposite: DEFEAT69+ kills its own carrier, so give the pair to the thing you want gone.70+- `shove-the-danger` originally had a loose `STOP` decoy. The level itself71+ still worked under W6 (PUSH beats STOP just makes `AND PUSH AND STOP` a72+ second answer). But three loose words made `--forbid 'ROCK IS PUSH'` hit73+ LIMIT at 2M, and still needed about 5M to prove. I removed the decoy and74+ narrowed the board to 7 by 7, so the proof takes 121,760 states.75+- `pass-it-on` was narrowed from 10 by 7 to 8 by 7 with the same layout logic,76+ so its `mustForm` proof fits the default budget (it took 9.5M before).77+78+Solver routes, for replay:79+80+```81+across-and-down URRRRRULLLDDRRRR82+company DLUURULDLUDDLUUDDDRR83+shove-the-danger ULURRDDRUULURDRURLDDRUR84+carry-the-pair DRRUULLURRRDRULURRLDRDDR85+pass-it-on DDLUUULUURLDDRULURRDDDDR86+down-to-miau ULLDDLURULULDDDDDDR87+spend-it-twice UUURRRLDDRUULURRLDRDDDRDRUUUULDLUDD88+```89+90+## Design notes91+92+- **Pinning is the level-design tool of this world.** Without custom rules,93+ the only way to make a sentence unbreakable is geometry. Three words in the94+ top or bottom row that touch a side edge can't be pushed in any direction.95+ The top row can be pushed *toward* only from below, and never away from the96+ edge. Every "given" sentence in this world is pinned like that. The97+ catch is that players can't see it (friction F-XW2).98+- **Pinning traps words too.** A word pushed into column 0, the last column,99+ the top row, or the bottom row can only move along that edge from then on.100+ An `IS` stuck under a pinned word in an edge column can never be taken out.101+ I moved the challenge's `FLAG WALL IS STOP` row away from the corner for102+ this reason. Otherwise the `IS` under `WALL` would have been unrecoverable103+ and the "spend it twice" route would have been impossible.104+- **Self-win is the default shortcut in a pure-grammar world.** Any loose105+ `WIN` near a `YOU` noun's word collapses the level to `X IS YOU` plus `X IS106+ WIN`. Levels 2 to 7 keep `MIAU` pinned so it can't take a loose predicate.107+- **Standing on the goal, then writing the rule.** In the first draft of108+ `carry-the-pair` (a `WIN+STOP` stack), the solver broke the flag's sentence,109+ walked onto the flag, and rebuilt `FLAG IS WIN+STOP` underneath the cat.110+ STOP doesn't eject an occupant, so that won. The W6 rework replaced the111+ whole stack, so the current level no longer has this route.112+- **The solver shows only the shortest route.** In `down-to-miau`, the first113+ draft was won by the unintended `ROCK IS YOU` down onto the shared `YOU`. I114+ only saw that by replaying the path. A wall (black square) now blocks that115+ column (friction F-XW10).116+- **Softlocks.** Every level can be softlocked with `YOU` still present by117+ pushing a needed word into an edge row or column. That's ordinary Sokoban,118+ and undo recovers from it. The no-`YOU` states reachable in this world are:119+ breaking `MIAU IS YOU` (only in `across-and-down`, where it isn't pinned),120+ walking into a DEFEAT rock (`shove-the-danger`), and, in `carry-the-pair`,121+ giving `YOU+DEFEAT` to MIAU or walking into a rock after only `ROCK IS122+ DEFEAT` forms. No level requires entering a no-`YOU` state. The engine rejects every move with no `YOU`, so undo or123+ restart is the only way out, and the stage's `NOTHING IS YOU` overlay124+ signals that.125+- **Spacing.** Given sentences sit in edge rows with at least one blank row126+ between them and the play area wherever the board allows. `company` puts127+ `FLAG IS WIN` and `WALL IS STOP` on the same bottom row, six cells apart,128+ and `shove-the-danger` and `pass-it-on` put two given sentences side by129+ side on the bottom row after being narrowed.130+131+## Parser and rule behaviour found while building132+133+All repros use the text-grid notation from the pack (`m r f w` are things,134+uppercase are words, `A+B` is a stack). They were first run on135+`src/engine.js` as of 2026-09-23 and **re-checked on the W6 engine** (D23).136+Items marked *resolved by W6* no longer hold, but are kept because they137+explain the older friction entries.138+139+1. **Crossing works as expected.** A horizontal and a vertical sentence can140+ share one `IS`, and each produces its own span (`state.sentences` has one141+ `h` and one `v`). Repro: `. FLAG . / MIAU IS YOU / . WIN .` gives `FLAG IS142+ WIN; MIAU IS YOU`.143+2. **A noun does double duty in one line.** `MIAU IS ROCK IS WIN` gives `MIAU144+ IS ROCK` and `ROCK IS WIN`, because scanning restarts at every noun. This145+ is what `pass-it-on` is built on.146+3. *Resolved by W6.* **`AND` was greedy and never backtracked.** Before W6,147+ `ROCK AND+IS FLAG` gave no rule. Under W6, a stacked cell contributes one148+ word per sentence path and every valid alternative is enumerated, so it149+ now gives `ROCK IS FLAG`. `MIAU AND+ROCK IS YOU` still gives only `ROCK IS150+ YOU`, now by design (the stacked `ROCK` starts its own sentence, and151+ `MIAU AND IS` isn't valid).152+4. **Stacks can't form or split during play.** Words are always `PUSH`, so a153+ word can never be moved into a cell that holds another word, and a stack154+ always moves as one piece. Every stack is placed by the author and stays155+ for the whole level. `carry-the-pair` relies on this. The only exception156+ is `SINK`, which destroys every word in the cell.157+5. **A stack still yields two same-length spans (unchanged by W6).** `FLAG IS158+ WIN+DEFEAT` gives spans `FLAG IS WIN` and `FLAG IS DEFEAT` on the same159+ axis. Neither contains the other, so the renderer draws two bars over the160+ same cells (F-XW3).161+6. **Words in front of or after a sentence are ignored.** `YOU MIAU IS WIN`162+ parses as `MIAU IS WIN`, and `MIAU IS YOU ROCK` as `MIAU IS YOU`. This is163+ expected, but it means a level can be **won at start** by an unnoticed164+ adjacency.165+7. *Resolved by W6.* **`X IS X` didn't protect.** Under W6, `ROCK IS ROCK`166+ blocks `ROCK IS FLAG` (checked: the rock stays a rock).167+8. *Resolved by W6.* **Conflicting transformations followed object order.**168+ Under W6, spatial reading order decides: with `ROCK IS FLAG` in the row169+ above `ROCK IS WALL`, the rock becomes a FLAG, and swapping the rows gives170+ a WALL. That's now visible on the board.171+9. **Transformation chains still advance one link per turn (unchanged by172+ W6).** With `ROCK IS FLAG` and `FLAG IS WALL`, a rock is a FLAG after reset173+ and becomes a WALL on the next accepted move. The level loads in an174+ intermediate state (F-XW6 b).175+10. **A transformation predicate in an `AND` chain can remove your own `YOU`176+ (unchanged).** `MIAU IS YOU AND ROCK` turns the cat into a rock at reset,177+ which leaves nothing as `YOU`, even though the board reads `MIAU IS YOU`.178+11. *Resolved by W6.* **`STOP` beat `PUSH`.** Under W6, `ROCK IS STOP AND179+ PUSH` is pushable. The old `shove-the-danger` decoy relied on the old180+ order and has been removed.181+12. *Resolved by W6, and now a lesson.* **`DEFEAT` didn't affect its own182+ carrier.** Under W6, `ROCK IS YOU+DEFEAT` destroys every rock at once,183+ even a lone rock, and `MIAU IS YOU AND WIN+DEFEAT` is destroyed, not won184+ (defeat resolves before win). The new `carry-the-pair` is built on185+ self-destruction.186+13. **`SINK` destroys words for good (unchanged).** Pushing a word into a187+ cell with a `SINK` thing removes the word and the thing.188+14. **Moving onto a thing on the same turn that makes it `WIN` wins189+ (unchanged by W6).** Repro: `MIAU IS YOU / FLAG IS . / . . f+WIN+STOP /190+ . . m`, step up. The cat pushes the `WIN+STOP` stack off the flag into191+ `FLAG IS _` and enters the flag cell on the same move. STOP is checked192+ before the move, and WIN after it, so the move wins.193+194+## Friction filed195+196+F-XW1 to F-XW13 (first pass) and F-XW14 to F-XW16 (the W6 rework) in197+[friction.md](friction.md). The most severe are F-XW2198+(pinned sentences are invisible), F-XW3 (stacks are hard to read), and F-XW8199+(solver capacity limits how big a challenge level can be).@@ -0,0 +1,999 @@
1+# Friction log (creator and player)2+3+## Triage (design, 2026-09-23, after Crate Works and Crossword)4+5+| Theme | Entries | Resolution |6+| --- | --- | --- |7+| Authoring format | F-CW1, F-XW1, F-CW14 | Engine ask W1: an ASCII `board()` helper with a legend and stable ids in `@miau/engine` |8+| Fixed rules and terrain | F-CW2, F-CW3, F-CW6, F-XW2, F-CW15 | Design D23: `fixed` words and `terrain` cells (visuals specified). Engine ask W2 |9+| Solver speed and intent | F-CW4, F-CW5, F-XW8, F-XW10, F2 | `--forbid` and `--replay` added to `tools/solve.mjs`. Engine ask W3: a fast `stepState` path and CI adoption |10+| Sokoban goal | F-CW10 | Engine ask W4: core `GOAL` property (spec in `docs/worlds/crate-works.md`) |11+| Gates and switches | F-CW17 | Engine ask W5: semantics plus 3 drafts in `crate-works.md` |12+| Precedence and transforms | F-XW5, F-XW6, F-XW4, F-XW9 | Design D23: Baba precedence. Engine ask W6: implement it, fix AND silently dropping a noun, make transforms deterministic |13+| Validation and metadata | F-CW8, F-CW9, F-XW11, F-XW7, F-CW16 | Engine ask W7 |14+| Presentation | F-CW7, F-CW11, F-CW12, F-CW13, F-XW3, F-XW12, F-XW13 | Design D23 (all except F-CW13, which inspect mode covers, and F-XW12, which ledger hover covers) |15+| Tidewater engine | F-TW1, F-TW2, F-TW3, F-TW5, F-TW10 | Engine asks W8 (rules not re-parsed after SINK: **fixed in `c3537ca`, and the design re-ran the repro and saw no phantom rule**), W9 (FLOAT), W10 (loader warnings), W3 (solver) |16+| Tidewater design | F-TW4, F-TW6, F-TW7, F-TW8, F-TW9 | D23 updates: crumble to a leftover noun, glyph `fallback`, no hazard pre-warning (by design). F-TW4 is fixed by the engine's facing change. F-TW8 is covered by transform-before-contact sequencing in the juice |17+| Resolved by W6 (Baba precedence and stacked grammar) | F-XW4, F-XW5 (a to c), F-XW6 (a) | **Resolved.** Engine W6 landed, and crossword was reworked against it (all 7 solve, 6 with a proven `mustForm`) |18+| Crossword W6 rework | F-XW14, F-XW15, F-XW16 | F-XW16 โ engine ask W14 (a rules-semantics version plus a load warning; CI already solves packs with `mustForm`). F-XW14 and F-XW15 โ W3 follow-ups (structured `mustForm` patterns; solver capacity shaping level size) |19+| Existing packs bypassable | F1 | **Resolved in plan.** The engine bundles the four worlds in their place, and `garden-basics` and `word-lab` leave the default INDEX (they stay importable) |20+21+22+Append-only. Design triages each entry into the worlds inbox (engine asks) or23+design.md (design changes).24+25+```md26+### F<n>: <short title>27+- from: <world or person>28+- side: creator | player29+- severity: blocker | high | medium | low30+- owner (suggested): engine | design | content31+- status: open32+33+What happened, with the exact file, level, or command.34+**Proposed fix:** โฆ35+```36+37+---38+39+### F1: Existing bundled packs are bypassable40+- from: design (solver pass)41+- side: player42+- severity: high43+- owner (suggested): content44+- status: open45+46+`node tools/solve.mjs` on the shipped mods: every level in `garden-basics`,47+`word-lab`, and `moon-cat` is winnable without moving a word (BYPASS), and48+`shared-control` and `moon-cat-intro` are TRIVIAL (3 and 5 steps). Core49+`two-things-can-win` is also a BYPASS (6 steps, `RRRRRR`). The packs don't yet50+teach the rule system they're named after.51+**Proposed fix:** rework these packs with the solver (content), and add the52+solver to CI as an authoring check (engine): BYPASS becomes a warning unless53+the level declares `allowBypass: true`.54+55+### F2: No solver or bypass check for authors56+- from: design57+- side: creator58+- severity: high59+- owner (suggested): engine60+- status: open (a stopgap is in `tools/solve.mjs`)61+62+A creator can't tell whether a level is solvable, or skippable, without63+playing it exhaustively. **Proposed fix:** adopt `tools/solve.mjs`, or an64+equivalent, as a supported `npm run solve -- <pack>`, and surface its result65+in the Workshop as "verified" once the level editor exists.66+67+### F-XW1: Coordinates by hand; no grid-based level helper68+- from: crossword (world-builder)69+- side: creator70+- severity: medium71+- owner (suggested): engine72+- status: open73+74+`@miau/engine` only offers `word(text, x, y)` and `object(noun, x, y)`, so75+a 12 by 8 board means counting 30 or more coordinates by hand. It's easy to76+put a word one cell off and silently break a crossing. For77+`mods/worlds/crossword.cjs` I wrote a 20-line `grid(id, rows, meta)` helper78+inside the pack. Rows are whitespace-separated cells: `.` for empty, `m r f w`79+for things, uppercase for words, and `A+B` for stacks. It also rejects ragged80+rows. Every other world will write its own version.81+**Proposed fix:** add `grid(rows, { legend, id })` to `@miau/engine`, using82+the same notation (stacks with `+`, ragged rows rejected, ids derived from83+`<level>-<token>-<x>-<y>` so they stay stable).84+85+### F-XW2: Sentences can only be pinned by geometry, and players can't see it86+- from: crossword87+- side: creator and player88+- severity: high89+- owner (suggested): design (presentation), engine (optional primitive)90+- status: open91+92+Every "given" sentence in Crossword (for example `MIAU IS YOU` in93+`company`, `ROCK IS DEFEAT` in `shove-the-danger`, `FLAG IS WIN` in94+`pass-it-on`) is made unbreakable by placing it in the top or bottom row95+flush against a side edge. That's the only way to get an unbreakable rule96+without custom rules. It has two costs. (1) Creators must know the trick,97+and it also traps words: an `IS` pushed under a pinned word in an edge column98+can never come out, which silently softlocked my first draft of99+`spend-it-twice`. (2) Players can't tell a pinned sentence from a loose one100+until they try to push it. My `company` hint has to say "stuck to the edge".101+**Proposed fix:** design: draw an edge-pinned or immovable word with a102+distinct cue (for example, a flat lip on the side it's pinned against) and103+show a short shake plus the blocked sound when a push fails at a board edge.104+Engine: an optional `fixed: true` on word records, rendered with the same105+cue, so authors don't have to rely on corners.106+107+### F-XW3: Stacked words are hard to read and draw two bars108+- from: crossword (`carry-the-pair`)109+- side: player110+- severity: high111+- owner (suggested): design (with engine for spans)112+- status: open113+114+`carry-the-pair` hinges on noticing that `WIN` and `DEFEAT` share one115+cell and can't be separated. Design's stack treatment (a ยฑ4ยฐ fan and 9%116+offset, with a `รN` badge only for 3+ items) is subtle at the 22px minimum117+cell. The board never says "these two move together". Also,118+`state.sentences` returns two same-axis spans of equal length for119+`FLAG IS WIN+DEFEAT` (`FLAG IS WIN` and `FLAG IS DEFEAT`). Neither contains120+the other, so the renderer draws two bars over the same cells.121+**Proposed fix:** design: always badge word stacks (`ร2`) and fan them wider122+when a stack sits in a sentence. The ledger should show a stacked predicate123+as a single `WIN+DEFEAT` chip. Engine: merge spans that cover the same cells124+into one span with `stackedIds`.125+126+### F-XW4: AND is greedy and never backtracks, so stacked connectives read inconsistently127+- from: crossword (parser probing)128+- side: creator129+- severity: medium130+- owner (suggested): engine131+- status: open132+133+`src/engine.js` `parseRules` consumes `AND noun` pairs before it looks for134+`IS`, and it never retries. Repro (text grid): `ROCK AND+IS FLAG` gives **no135+rule**, but `ROCK AND+IS WIN` gives `ROCK IS WIN`. `MIAU AND+ROCK IS YOU`136+gives only `ROCK IS YOU`, so `MIAU` is dropped. I avoided building on this137+because it looks accidental.138+**Proposed fix:** decide the rule. Either the parser tries every reading of139+a stacked cell (backtracking), or authoring validation warns when a stack140+mixes a connective with a noun or with `IS`. Document the choice in141+`docs/modding.md`.142+143+### F-XW5: Rule precedence differs from the reference game and isn't documented144+- from: crossword145+- side: creator and player146+- severity: medium147+- owner (suggested): engine (document), design (decide)148+- status: open149+150+Found while building pure-grammar levels:151+(a) `ROCK IS STOP AND PUSH` is a wall, because `planMove` checks STOP before152+PUSH. In the reference game, PUSH wins.153+(b) `MIAU IS YOU AND DEFEAT` doesn't destroy the cat, and `MIAU IS YOU AND154+WIN+DEFEAT` wins, because contacts only resolve in cells with 2 or more155+occupants. In the reference game, the carrier is destroyed.156+(c) `ROCK IS ROCK` doesn't stop `ROCK IS FLAG`. In the reference game, `X IS157+X` blocks transformation.158+`shove-the-danger` (the STOP decoy) and `carry-the-pair` (the self-win with159+DEFEAT) depend on (a) and (b). If the engine changes, those two levels change.160+**Proposed fix:** publish a precedence table in `docs/modding.md` (STOP vs161+PUSH, self-contact, `X IS X`), and mark each behaviour intentional or a bug.162+Worlds can then cite it.163+164+### F-XW6: Transformation conflicts and chains are invisible and order-dependent165+- from: crossword166+- side: creator and player167+- severity: medium168+- owner (suggested): engine169+- status: open170+171+(a) With `ROCK IS FLAG` and `ROCK IS WALL` both active, the winner is172+whichever word comes first in the level's `objects` array. Swapping two rows173+of an otherwise identical level swaps the result. (b) With `ROCK IS FLAG` and174+`FLAG IS WALL`, a rock is a FLAG after reset and becomes a WALL on the next175+accepted move, one link per turn. The level loads in an intermediate state.176+(c) `MIAU IS YOU AND ROCK` turns the cat into a rock at reset, so the level177+starts with nothing as YOU even though the board says `MIAU IS YOU`.178+**Proposed fix:** resolve transformations to a fixed point each turn, with179+cycle detection. Pick conflicts deterministically in reading order (top to180+bottom, left to right), not object order. Have validation warn when a level181+starts with no YOU.182+183+### F-XW7: The default noun glyphs are hidden in src/main.js184+- from: crossword185+- side: creator186+- severity: medium187+- owner (suggested): engine188+- status: open189+190+The brief for this world was "use only nouns with default emoji". The only191+list is a `glyphs` object literal on line 67 of `src/main.js` (MIAU, CAT,192+BABA, FLAG, ROCK, WALL, WATER, GRASS, STAR, KEY, DOOR, KEKE, THORN, GEM). It193+isn't exported, documented, or reflected in `vocabulary`, and nothing tells194+an author that a noun without a palette entry falls back to a text label.195+**Proposed fix:** move the table to a shared module (for example196+`src/glyphs.js`), export it, list it in `docs/modding.md`, and have197+`loadMod` warn (not fail) when a noun has neither a palette entry nor a198+default glyph.199+200+### F-XW8: Solver capacity limits challenge design201+- from: crossword (`spend-it-twice`)202+- side: creator203+- severity: high204+- owner (suggested): engine205+- status: open206+207+`tools/solve.mjs` builds a fresh `createGame` (with structured clones) for208+every expansion. A 12 by 9 draft of `spend-it-twice` with three loose words209+hit `LIMIT after 2000001 states` in 2m39s. I had to shrink it to 9 by 7 to210+verify it (35 steps, 41s). A full pass over this 7-level pack at `--max211+2000000` takes about 2m45s. So the solver, not the design, decided the size of212+the world's hardest level.213+**Proposed fix:** step a single mutable state with an undo stack instead of214+`createGame` per node, use a compact integer state key, and add an optional215+A* mode (for example, distance of loose words to target cells) or a216+push-level macro search for long levels.217+218+### F-XW9: Standing on a thing and then writing its rule wins219+- from: crossword (`carry-the-pair` first draft)220+- side: creator221+- severity: medium222+- owner (suggested): design (decide), engine (solver flag)223+- status: open224+225+The first draft had `FLAG IS WIN+STOP`. The solver broke the sentence, walked226+onto the flag, and rebuilt `FLAG IS WIN+STOP` vertically under the cat. That227+won in 16 steps, because STOP never ejects an occupant. It's a legitimate228+reference-game trick, but it defeats any "the goal is STOP" framing, and229+BYPASS doesn't catch it (words did move). I fixed it by using DEFEAT230+instead.231+**Proposed fix:** have the solver print the rules active at the winning step232+and flag `WON-WHILE-OVERLAPPING` when the win came from a rule formed while233+YOU already shared the cell, so authors can decide.234+235+### F-XW10: The solver shows one route and can't check the intended one236+- from: crossword (`down-to-miau`)237+- side: creator238+- severity: medium239+- owner (suggested): engine240+- status: open241+242+`down-to-miau` was meant to be solved with `ROCK IS MIAU` (a243+transformation). The shortest route was `ROCK / IS / YOU` down onto the244+shared YOU, and I only found that by writing a replay script245+(`/tmp/xw/replay.mjs`) to print the rules added on each step. I then added a246+wall (a black square) to block that column.247+**Proposed fix:** add solver flags `--explain` (print rules added or removed248+along the path, and the board at the end), `--require "ROCK IS MIAU"` (the249+route must form this rule), and `--forbid "ROCK IS YOU"` (report whether a250+route avoiding it exists).251+252+### F-XW11: Validation ignores world metadata and the quality-bar constraints253+- from: crossword254+- side: creator255+- severity: low256+- owner (suggested): engine257+- status: open258+259+`loadMod` accepts `manifest.world` (any shape), `category` (any value),260+`introduces` and `reuses` (anything), and boards up to 128 by 128. The worlds261+README requires boards of at most 14 by 10 and `category` in `required |262+optional | challenge`, but a typo such as `category: 'challange'` or a263+15-wide board passes silently.264+**Proposed fix:** validate `manifest.world` (`glyph` of at most 16 chars,265+`tagline` of at most 80 chars, `accent` in the six property tokens) and266+`category`. Emit non-fatal warnings for boards over 14 by 10 and for267+`introduces` that isn't a string array.268+269+### F-XW12: With crossing words, the ledger doesn't show which sentence a word is in270+- from: crossword (`across-and-down`, `pass-it-on`)271+- side: player272+- severity: medium273+- owner (suggested): design274+- status: open275+276+In a crossing, the shared `IS` is fully opaque as long as either sentence is277+active. If the player breaks the vertical sentence, the `IS` still looks278+active, and only the dimmed `FLAG` shows that anything changed. In a chain279+like `ROCK IS FLAG IS WIN`, the middle `FLAG` belongs to two rules. The280+ledger lists rules but doesn't point back to their tiles or axis.281+**Proposed fix:** hovering or focusing a ledger rule highlights its source282+tiles (`rule.sourceIds` already exists) and draws its bar brighter. Show a283+small axis mark (across or down) on ledger rules that came from a vertical284+span.285+286+### F-XW13: SINK silently deletes words287+- from: crossword (parser probing)288+- side: player289+- severity: low290+- owner (suggested): design291+- status: open292+293+A word pushed into a cell with a SINK thing is destroyed along with the294+thing (`MIAU IS YOU / ROCK IS SINK / m STOP r`, step right: the `STOP` word295+and the rock are gone). In a pure-grammar level, that can remove a word the296+solution needs, and the only sign is the missing tile.297+**Proposed fix:** show a destroyed word with its own effect (the letters298+scattering) and a ledger note like `STOP IS GONE`, so the player knows to299+undo.300+301+### F-CW1: No map format; every level is hand-counted coordinates302+- from: Crate Works303+- side: creator304+- severity: high305+- owner (suggested): engine306+- status: open307+308+Seven boards of 40 to 110 entities each would have meant writing hundreds of309+`word('IS', 8, 3, 'x-is')` calls and counting columns by hand. Crate Works310+defines its own `board(rows, meta)` helper at the top of311+`mods/worlds/crate-works.cjs`: one character per cell, a legend (`#` WALL,312+`$` CRATE, `~` GAP, uppercase letters for words), and row-width checks with313+readable errors ("row 2 is 11 wide, expected 13"). That's about 25 lines314+every pack author will rewrite.315+**Proposed fix:** add `map(rows, legend, meta)` to `@miau/engine`. Legend316+values are `'WORD'`, `{ noun }`, or an array for stacks. Include row-width and317+unknown-character errors that name the level, column, and row, and a318+documented default legend so packs and bug reports read the same way.319+320+### F-CW2: Rule words can't be fixed, so every level spends rows sealing an "office"321+- from: Crate Works322+- side: creator323+- severity: high324+- owner (suggested): engine325+- status: open326+327+Every rule a level depends on (`WALL IS STOP`, `GAP IS SINK`, `TRUCK IS WIN`)328+can be broken by the player unless it's physically sealed away. The only329+tools are a full wall row, which needs `WALL IS STOP` and so is circular, or330+packing row 0 so the board edge stops upward pushes. Two of my drafts fell to331+this. `load-order` v1 was solved in 15 by pushing `STOP` up out of332+`WALL IS STOP` from the room below, which made the walls passable. That's a333+word move, so the solver didn't flag BYPASS. `spend-it-last` v1 was solved by334+sinking a word into a row-0 gap and then pushing `GAP IS SINK` sideways.335+**Proposed fix:** add a per-word `fixed: true` flag. The engine treats it as336+immovable and the renderer draws it bolted: no lip, a flat tile, a small rivet337+in the gutter. Also add a documented "sealed rules" slot outside the board for338+rules that should never be editable (a ledger-only rule).339+340+### F-CW3: Solid terrain requires a breakable rule341+- from: Crate Works342+- side: creator343+- severity: medium344+- owner (suggested): engine345+- status: open346+347+A `WALL` does nothing unless `WALL IS STOP` is on the board, so every level348+pays three tiles plus sealing (F-CW2) just to have walls. Players also have to349+read that sentence in every level even though it is never the puzzle.350+**Proposed fix:** allow level-declared terrain nouns that are `STOP`351+implicitly: `terrain: ['WALL']`. They're shown in the ledger as a greyed352+built-in rule and can still be overridden by a written rule if a level wants353+that.354+355+### F-CW4: tools/solve.mjs is too slow and runs out of memory on normal board sizes356+- from: Crate Works357+- side: creator358+- severity: high359+- owner (suggested): engine360+- status: open361+362+The solver builds a new `createGame` and runs `structuredClone` for every363+node, about 25k states/s. `spend-it-last` (14ร5) needed `--max 2000000` and364+70 s. A 13ร9 room with four loose words hit the limit, and my faster365+re-implementation ran out of memory at 4 GB storing full states in the BFS366+frontier. I had to write a compact simulator (`/tmp/cw/sim.mjs`, about 30ร367+faster, differentially tested against `src/engine.js` with random walks) to368+iterate at all.369+**Proposed fix:** give the engine a `stepState(compactState, dir)` fast path370+with no events and no clone. Store parent pointers instead of path strings.371+Hash with typed arrays. Add `--replay <path>` to print frames, because I also372+had to write that.373+374+### F-CW5: The solver can't say whether the *intended* rule is needed375+- from: Crate Works376+- side: creator377+- severity: medium378+- owner (suggested): engine379+- status: open380+381+BYPASS only catches "no word moved". Unintended rule edits pass silently. The382+first `crates-move` was won with `CRATE IS WIN` (WIN pushed onto383+`CRATE IS PUSH`) instead of `TRUCK IS WIN`, and `load-order` v1 was won by384+breaking `WALL IS STOP`. I added `--forbid "RULE"` to my own solver to prove385+that `GAP IS CRATE` and `CRATE IS YOU` are necessary.386+**Proposed fix:** add `--forbid "X IS Y"` and `--require "X IS Y"` to387+`tools/solve.mjs`, and optional level metadata `intended: { rules: [...] }`388+that the checker verifies (unsolvable without them). Also print the rule set389+at the moment of winning, which exposes most accidental shortcuts at a glance.390+391+### F-CW6: Mods can't declare their own terrain slab; the WALL palette glyph is silently ignored392+- from: Crate Works393+- side: creator394+- severity: medium395+- owner (suggested): engine396+- status: open397+398+`src/render/board.js` draws a slab only for nouns in `options.wallNouns`,399+which defaults to `['WALL']`, and nothing passes that option. My palette entry400+`WALL: '๐งฑ'` is ignored without a warning, and a themed noun like `SHELF` or401+`RACK` would render as a small emoji instead of terrain.402+**Proposed fix:** a palette flag `{ glyph, terrain: true }`, or403+`manifest.terrain: ['SHELF']`, passed through to `wallNouns`. The loader404+should warn when a palette entry will never be drawn.405+406+### F-CW7: A wall that isn't STOP still looks like solid terrain407+- from: Crate Works408+- side: player409+- severity: medium410+- owner (suggested): design411+- status: open412+413+The slab rendering depends on the noun, not the rules. If `WALL IS STOP` is414+broken, or a level makes walls `PUSH`, the board still shows solid racking,415+and the player learns that walls are passable only by bumping into them.416+Crate Works avoids this by always sealing `WALL IS STOP` (F-CW2), but other417+worlds will hit it.418+**Proposed fix:** draw terrain slabs only while the noun is `STOP`. Otherwise419+draw a hatched outline (passable) or a slab with a lip (pushable).420+421+### F-CW8: manifest.world is accepted with no validation422+- from: Crate Works423+- side: creator424+- severity: low425+- owner (suggested): engine426+- status: open427+428+I added `world: { glyph, tagline, accent }` per D20. `loadMod` accepts any429+shape there (a typo such as `accent: 'pushh'` or an 200-character tagline430+passes), so mistakes would only show up at render time, if at all.431+**Proposed fix:** validate `world.glyph` (1 to 16 characters), `tagline`432+(80 characters or fewer, no U+00B7), and `accent` (one of433+`you|push|stop|win|sink|defeat`) in `validateMod`, with the same readable434+error lines as other fields.435+436+### F-CW9: Level metadata (category, introduces, reuses) has no schema437+- from: Crate Works438+- side: creator439+- severity: low440+- owner (suggested): design441+- status: open442+443+The README requires `category` and `introduces`, but nothing validates them,444+and there's no convention for lessons that aren't a single token. I used445+strings like `'sinking words'`, `'shared YOU'`, and `'noun transform'`446+alongside tokens like `'GAP'`. A tool that builds a curriculum view can't447+tell these apart.448+**Proposed fix:** validate `category` against `required|optional|challenge`.449+Split `introduces` into `words: [...]` (tokens, checked against the level)450+and `ideas: [...]` (free text). Document `reuses`.451+452+### F-CW10: No way to express a real Sokoban goal ("every crate on a dock")453+- from: Crate Works454+- side: player455+- severity: high456+- owner (suggested): engine457+- status: open458+459+WIN only fires when a YOU shares a cell with a WIN thing, so multi-box levels460+can't have a goal that depends on where the boxes are. Crate Works works461+around it (holes that eat boxes, pushing a `PUSH AND WIN` truck onto a parked462+YOU, crate-as-YOU), but the genre's core satisfaction, the last box clicking463+home, isn't available.464+**Proposed fix:** a core property `GOAL` (`DOCK IS GOAL`). The level is won at465+end of turn when a YOU exists, at least one GOAL thing exists, and every GOAL466+thing shares its cell with another `PUSH` entity (words count). Full spec, and467+two prototype-verified draft levels, are in `docs/worlds/crate-works.md`468+under "Goal feature spec".469+470+### F-CW11: A sunk word takes its rule away with no announcement471+- from: Crate Works472+- side: player473+- severity: medium474+- owner (suggested): design475+- status: open476+477+In `spend-it-last`, `load-order`, and `night-shift`, the key event is a word478+entering a gap: the word, the gap, and possibly a rule vanish in one turn. The479+ledger just loses a line. A player who sank `PUSH` too early doesn't learn why480+the crate stopped moving.481+**Proposed fix:** when a destroyed entity is a word that was in an active482+sentence, show a short float of the lost rule, struck through483+(`CRATE IS PUSH` with a line through it), and play a distinct sink sound for484+words. The `destroyed` event already carries `text`.485+486+### F-CW12: Lockstep YOUs give no feedback about who was blocked487+- from: Crate Works488+- side: player489+- severity: medium490+- owner (suggested): design491+- status: open492+493+`in-step` and `back-it-up` depend on one YOU being held still by a wall while494+the other moves. Right now the blocked YOU just doesn't move. That's the most495+important information in the level, and it's shown by nothing happening.496+**Proposed fix:** a per-entity bump for every YOU whose move was refused, even497+when another YOU moved and the turn was accepted. The engine returns only the498+first `blocked`, so `settled()` should return `blocked: []` for every refused499+YOU.500+501+### F-CW13: Crates without PUSH look exactly like pushable crates502+- from: Crate Works503+- side: player504+- severity: medium505+- owner (suggested): design506+- status: open507+508+In `night-shift`, `GAP IS CRATE` turns 13 gaps into crates at once. Because509+`CRATE IS PUSH` was just dismantled, they're walk-through decoration, but they510+look identical to the pushable crate from level 1, so the solution looks511+impossible until the player walks into one. The mass transform also needs a512+strong cue.513+**Proposed fix:** give things with no active properties a subtle "inert"514+treatment (for example 70% opacity or no ground shadow). When a transform515+changes more than about 4 entities at once, use a staggered pop.516+517+### F-CW14: Position-derived object ids change when the map is edited518+- from: Crate Works519+- side: creator520+- severity: low521+- owner (suggested): engine522+- status: open523+524+To avoid id boilerplate, my `board()` helper derives ids from the level id525+and starting cell (`night-shift-3-4`). Moving one tile renames it. That's526+harmless today, but anything keyed by entity id (replays, the facing map,527+future gate state) would silently break across pack versions.528+**Proposed fix:** document that replays are move lists only, and state which529+features, if any, persist entity ids. If some do, have the map helper assign530+ids by reading order per noun (`crate-1`, `crate-2`), which survives most531+edits.532+533+### F-CW15: 14ร10, sealed rule rows, and blank-row spacing compete for space534+- from: Crate Works535+- side: creator536+- severity: low537+- owner (suggested): design538+- status: open539+540+A sealed rule office costs 2 rows (words and walls), or 4 with the "blank row541+between stacked sentences" rule. On a 10-row board that leaves 6 rows for the542+puzzle. `load-order` ends up with two rule rows separated only by a wall row,543+and `spend-it-last` packs four sentences into one row with single-wall544+separators.545+**Proposed fix:** a sealed-rules strip outside the board (see F-CW2), or546+count sealed rows separately from the 14ร10 limit.547+548+### F-CW16: The engine changed under a live authoring session549+- from: Crate Works550+- side: creator551+- severity: low552+- owner (suggested): engine553+- status: open554+555+Partway through, `src/engine.js` gained uncommitted automata support and the556+solver was updated. Core semantics didn't change, but I only found that out by557+diffing and re-running a differential test.558+**Proposed fix:** keep a short `ENGINE-CHANGES.md` or version note that says559+whether core turn resolution changed. Have the solver print the engine560+version it ran against.561+562+### F-CW17: The engine snapshot has no per-entity mechanism state (gates and switches)563+- from: Crate Works564+- side: creator565+- severity: medium566+- owner (suggested): engine567+- status: open568+569+The gates-and-switches proposal needs a gate to remember `open` (a jam depends570+on history), and object records need a `channel`. Today `reset()` rebuilds571+objects from a fixed set of fields, so extra fields are dropped, and572+`object()` has no options argument. Proposed semantics, and three573+prototype-verified scenarios, are in `docs/worlds/crate-works.md` under574+"Gates and switches (proposal)".575+**Proposed fix:** let object records carry validated extra fields576+(`channel`), keep `open` in `state.objects` so undo and share just work, and577+include it in the solver hash.578+579+### F-CK1: An undeclared property word silently becomes a noun and transforms things580+- from: Clockwork581+- side: creator582+- severity: blocker583+- owner (suggested): engine584+- status: open585+586+The parser treats any word that isn't `IS`, `AND`, or a known property as a587+noun. On the shipped engine, the draft level `key-and-door`588+(`/tmp/clockwork-proto/clockwork-pairs.cjs`) contains `DOOR IS SHUT`. It loads589+without a warning, and at reset every door turns into a `SHUT` thing with no590+glyph. The door stops being `STOP`, so the lock is gone. My first scratch test591+did the same with `GEAR IS MOVE`: every gear became a `MOVE` thing. A creator592+who misspells a property, or uses one from a newer build, gets a level that593+quietly plays wrong. `validateLevel` also accepts `{ noun: 'TICK' }` in a level594+that declares `TICK` as an automaton property.595+**Proposed fix:** at load time, warn about any predicate word that isn't a596+known or declared property and isn't a noun used by the pack's objects,597+palette, or vocabulary. The warning should say something like "SHUT isn't a598+thing in this pack. Did you mean a property?" Also reject an object whose noun599+is a declared property word. See ask 1 in `docs/worlds/clockwork-api.md`.600+601+### F-CK2: New level features are silently ignored by builds that don't know them602+- from: Clockwork603+- side: creator604+- severity: high605+- owner (suggested): engine606+- status: open607+608+`level.automata` shipped without a schema bump, and `mods.js` passes unknown609+level keys through. Take a pack that uses `automata`, or the proposed610+`pairs`, or even a typo like `automaton`. It loads on a build that doesn't611+know the field, and its property words turn into nouns (F-CK1). The only612+version gate, `manifest.engine`, is still `1`.613+**Proposed fix:** add `manifest.features: ['automata', 'pairs']`. The loader614+rejects any feature it doesn't know with the player line "This pack needs a615+newer Miau." Validation also warns about unknown level keys. See ask 8 in616+clockwork-api.md.617+618+### F-CK3: A player has no way to learn what a custom property does619+- from: Clockwork620+- side: player621+- severity: high622+- owner (suggested): design623+- status: open624+625+`TICK` is a word no player has seen before. The stage draws its tile and a626+next-step arrow on each actor, but nothing says "this thing repeats its627+steps, one for each of your turns". Clockwork puts a one-sentence628+explanation in each level hint, but hints nudge. They shouldn't have to629+define vocabulary.630+**Proposed fix (design):** the first time a level shows an unfamiliar property631+tile, show a small card: the tile, its one-line `describe`, and a short demo632+of the pattern. After that, the line shows under the ledger entry.633+**Proposed fix (engine):** accept a pack-level glossary,634+`properties: { TICK: { accent, describe } }`, where `accent` is a property635+colour token and `describe` is at most 80 characters. Clockwork already636+declares it, and today's loader ignores it.637+638+### F-CK4: The renderer hard-codes the custom property list639+- from: Clockwork640+- side: creator641+- severity: medium642+- owner (suggested): engine643+- status: open644+645+`src/render/board.js` hard-codes its own `PROPERTIES` set, which now includes646+`TICK`, and colours cues with `colors.TICK`. The engine takes property words647+from `level.automata[].property`, but the renderer doesn't. A pack that names648+its property `WIND` or `SPIN` gets a noun-shaped tile with no chamfer and no649+cue colour, so it reads as a noun.650+**Proposed fix:** derive the property set and cue colour from the level's651+declared properties and the glossary `accent` (F-CK3), and remove the652+hard-coded word.653+654+### F-CK5: Players see only the next beat, and nothing at all for a paused clock655+- from: Clockwork656+- side: player657+- severity: medium658+- owner (suggested): design659+- status: open660+661+`drawAutomataIndicators` draws one arrow for the next step, and only while the662+rule is active. In `wind-it-up` the player can't preview what the mouse will663+do before winding it. In `escapement` (29 steps) the plan depends on where the664+mouse will be 4 to 6 beats from now. Two facts are also invisible: a paused665+clock keeps its phase, and handing `TICK` to another noun keeps that phase666+too.667+**Proposed fix:** add a pattern strip for each automaton (in the ledger or a668+HUD row) that shows every step as a small arrow or pause mark, highlights669+the current beat, and stays visible, dimmed, while paused. Keep the on-sprite670+arrow for the next beat only.671+672+### F-CK6: A moving hazard can't guard anything, because contacts only run at end of turn673+- from: Clockwork674+- side: creator675+- severity: medium676+- owner (suggested): design677+- status: open678+679+Contacts resolve once, after the automata beat. A cat can step into a `DEFEAT`680+cog's cell on the turn the cog moves away, swap cells with it head-on, or let a681+`STOP` patroller walk onto it and then leave. Every patrol-guard level I drafted682+(`stop-the-clock` in three layouts) had a timing bypass or blew up the solver.683+The same rule is a feature elsewhere: a thing that is both `YOU` and `TICK`684+moves twice and skips the cell in between, which is what `double-time` uses to685+cross a cog line.686+**Proposed fix:** document the "end-of-turn contact" rule and its two687+consequences (swap-through, and double moves skipping cells) in688+`docs/modding.md` next to the automata schema. If guards matter later, the689+engine could add an opt-in `contact: 'each-move'` per automaton. That needs a690+spec, so don't add it silently.691+692+### F-CK7: The automata spec, the loader, and the engine disagree693+- from: Clockwork694+- side: creator695+- severity: medium696+- owner (suggested): engine697+- status: open698+699+Three mismatches:700+- `openspec/changes/add-rule-driven-automata/specs/.../spec.md` describes one701+ automaton with step IDs and property additions and removals. The code702+ implements many automata with `move` and `wait` steps.703+- The engine accepts `'U'`/`'W'` shorthand steps, but `mods.js` rejects them.704+- The engine's `MAX_AUTOMATA` is 32, and so is the loader's, but only the705+ loader rejects a `phase` beyond the pattern.706+707+I authored against the code, and I only knew which one was true by reading708+both files.709+**Proposed fix:** update the spec to the shipped model, and have the loader710+and engine share one validator, or at least one table of limits and accepted711+forms.712+713+### F-CK8: A function anywhere in a pack gives an unhelpful load error714+- from: Clockwork715+- side: creator716+- severity: low717+- owner (suggested): engine718+- status: open719+720+My first behaviour-API draft exported721+`properties: { MOVE: { run(view, self) { ... } } }`. `loadMod` fails with722+`run(){} could not be cloned.`, because `normalizeMod` runs `structuredClone`723+over the export. Packs are data-only by design (URL links and the pack library724+store no code), but the message doesn't say so.725+**Proposed fix:** before cloning, walk the export for functions and report726+"levels[2].automata[0].pattern: packs are data only; functions aren't727+supported" with a path.728+729+### F-CK9: BYPASS counts automaton pushes as the player's work730+- from: Clockwork731+- side: creator732+- severity: medium733+- owner (suggested): engine734+- status: open735+736+`tools/solve.mjs` flags BYPASS only when no word moves at all. On a level where737+a mouse pushes the `WIN` word, a player who just walks and waits is never738+flagged. Yet "the player never touched a word" is exactly the skip an author739+wants to know about. `mouse-keeps-time` is hands-off on purpose, and nothing in740+the tool output says so. My prototype verifier741+(`/tmp/clockwork-proto/verify.mjs`) adds a **HANDS-OFF** flag: won without the742+player's own push chain moving a word (moves tagged `cause: 'automaton'` are743+ignored). Speed is the same problem as F-CW4. `mouse-hole` and `escapement`744+return LIMIT at the default `--max 300000` and need 3 to 4 million expansions,745+about 3 minutes each.746+**Proposed fix:** add HANDS-OFF to `tools/solve.mjs` for levels with automata,747+and let a level declare `allowHandsOff: true`. For speed, see F-CW4.748+749+### F-TW1: A sunk word's rule stays active for the next move (ghost rule)750+- from: Tidewater751+- side: creator and player752+- severity: high753+- owner (suggested): engine754+- status: open755+756+`step()` parses rules, transforms, and then resolves contacts, but it never757+re-parses rules after contacts remove things. If a sentence is still whole on758+the turn one of its words sinks (for example, pushing `ROCK IS STOP` right759+until `STOP` enters water), `state.rules` still contains `ROCK IS STOP`. The760+next move into a rock is rejected with reason `stop`. A rejected move doesn't761+refresh rules either, so the ghost lasts until some other move is accepted.762+The ledger shows a rule whose word is gone. Repro: `/tmp/tw/repro.mjs`, also763+in `docs/worlds/tidewater.md`, "Surprising engine behaviour" 1. No Tidewater764+level depends on this. My search port had to copy the stale behaviour to765+match the engine.766+**Proposed fix:** call `refreshRules()` (and `transform()`) again after767+`resolveContacts()` when anything was removed. Add a regression test with the768+repro. Decide whether the post-removal transform also applies in the same769+turn. I suggest yes, to reach a fixed point.770+771+### F-TW2: "Become a boat to cross the water" can't be expressed772+- from: Tidewater773+- side: creator774+- severity: medium775+- owner (suggested): design (decide), engine776+- status: open777+778+The world brief asked for "becoming a CRAB or BOAT to cross what MIAU779+cannot". In the core engine, SINK removes every occupant of the cell whatever780+its noun, so a BOAT sinks exactly like a cat. DEFEAT is the only hazard that781+depends on identity (it only removes YOU things), and nothing grants immunity.782+Identity puzzles therefore reduce to "become whoever is already on the other783+side" (`sideways`), or to changing the hazard itself (`shell-game`,784+`last-boat-out`).785+**Proposed fix:** add `FLOAT` as the next core property: a FLOAT thing and a786+SINK thing in one cell don't interact, as in the reference game. It's the787+smallest addition that makes vehicles, bridges, and "moored" objects788+authorable. Until then, document in `docs/modding.md` that SINK ignores789+identity.790+791+### F-TW3: Anything placed on water vanishes at reset, silently792+- from: Tidewater793+- side: creator794+- severity: medium795+- owner (suggested): engine796+- status: open797+798+`reset()` resolves contacts, so a boat moored on a `WATER IS SINK` cell, or a799+jelly floating in the sea, is destroyed before the first frame. The level800+loads as if the author forgot the object. Nothing reports it: not `loadMod`,801+not the solver. This also blocks design's crumbling-tile default ("place WATER802+under it"). See "Crumbling tiles (proposal)" in `docs/worlds/tidewater.md`.803+**Proposed fix:** have validation (or the solver) warn "N things are804+destroyed at reset (BOAT at column 4, row 2 sinks)". Longer term, FLOAT805+(F-TW2) makes these stacks legal.806+807+### F-TW4: A palette entry for MIAU silently turns off the cat's facing808+- from: Tidewater809+- side: creator and player810+- severity: medium811+- owner (suggested): engine812+- status: fixed in the working tree (`src/render/facing.js` now inherits built-in facing when the glyph matches); kept for the record813+814+`src/render/board.js` only applies the built-in `DIRECTIONAL_NOUNS` facing815+when a pack has **no** palette entry for the noun. Every example pack816+(`word-lab`, `garden-basics`) writes `MIAU: '๐'` as a plain string, so in817+those packs the cat never turns around, and nothing warns about it. I only818+found this by reading the renderer.819+**Proposed fix:** when a pack's plain-string glyph is identical to the820+built-in glyph for that noun, inherit the built-in `faces`. Update the821+example packs to `{ glyph: '๐', faces: 'left' }`, and say so in822+`docs/modding.md`.823+824+### F-TW5: A syntax error in a pack gives no line number825+- from: Tidewater826+- side: creator827+- severity: low828+- owner (suggested): engine829+- status: open830+831+An apostrophe in a single-quoted hint (`'You can't reach...'`) produced832+`Could not load tidewater.cjs: Unexpected identifier 't'`. There was no line833+or column, because the source runs through `new Function`, and the834+`sourceURL` comment doesn't surface in the message. With a pack made of text835+maps, finding it meant bisecting by hand. An apostrophe in a hint is also the836+most likely mistake a writer will make.837+**Proposed fix:** before `new Function`, parse the source with a cheap838+syntax check that reports line and column, or catch the `SyntaxError` and839+read the line from `error.stack`, adjusting for the 2 prepended lines. In the840+Workshop error note, show the offending line.841+842+### F-TW6: Newer emoji (๐ชผ, ๐ชธ) have no fallback glyph843+- from: Tidewater844+- side: player845+- severity: low846+- owner (suggested): design847+- status: open848+849+๐ชผ (Unicode 15, 2022) and ๐ชธ (Unicode 14) render well on current Apple fonts,850+but show tofu on older Android and Windows 10. The jelly is the hazard in 5851+of 7 levels, and an unreadable hazard is a fairness problem. The palette852+takes exactly one glyph per noun, so a creator has no way to say "or ๐ if853+that's missing".854+**Proposed fix:** allow `palette[NOUN] = { glyph: '๐ชผ', fallback: '๐' }`,855+with the renderer checking glyph support once (canvas `measureText` width856+against the tofu width). Or document a "safe emoji" floor (Unicode 13) for857+bundled packs.858+859+### F-TW7: Walking into water is instant and untelegraphed860+- from: Tidewater861+- side: player862+- severity: medium863+- owner (suggested): design864+- status: open865+866+SINK resolves in the same step as the move. The first time a player learns867+that water is deadly is when the cat disappears, and in `one-for-the-tide`868+the sacrifice of a crab reads exactly like a mistake. The world depends on869+the player *choosing* what to feed the sea, so the difference between "fed870+the sea" and "fell in" matters. Undo recovers it, but the lesson arrives as871+a punishment.872+**Proposed fix:** (a) a one-frame "about to sink" wobble on a YOU thing whose873+next step in the held direction enters a SINK or DEFEAT cell. It's874+presentation-only and computed from `properties()`. (b) A distinct sink875+effect when a YOU is lost while other YOUs remain ("one for the tide"), not876+the same effect as a crate or word sinking.877+878+### F-TW8: Transformation beats contact, and the player can't see why they survived879+- from: Tidewater880+- side: player881+- severity: low882+- owner (suggested): design883+- status: open884+885+In one step you can complete `JELLY IS SHELL` and walk onto a jelly. The886+jelly becomes a shell before DEFEAT is checked, so you survive (repro in887+`docs/worlds/tidewater.md`, behaviour 2). It's a good moment, but the888+transform tween and the move tween play together, so it looks like the cat889+walked into a jellyfish and nothing happened.890+**Proposed fix:** play the transform pop *before* the move settles (a891+40 to 60 ms lead) whenever a moved YOU ends on an entity that transformed892+this step, so the order of events is visible.893+894+### F-TW9: A crumbling tile can't have water "under" it in the current contact model895+- from: Tidewater (crumbling tiles proposal)896+- side: creator897+- severity: medium898+- owner (suggested): design (decide), engine899+- status: open900+901+Design's default for crumbling tiles says "a level can place WATER under it902+to make the hole deadly". A cell with `DRIFT` and `WATER` under903+`WATER IS SINK` is destroyed at reset (F-TW3), so that authoring pattern904+can't exist without a floor layer. Full semantics and two draft levels are in905+`docs/worlds/tidewater.md`, "Crumbling tiles (proposal)".906+**Proposed fix:** a crumble *becomes* a leftover noun907+(`leaves: 'WATER'`, keeping the entity id) instead of revealing what's908+underneath. Express counts above 1 as a leftover chain (SAND, then SHOAL,909+then WATER) rather than a counter, so the state stays in `noun` and undo,910+sharing, and the solver hash need no change.911+912+### F-TW10: Solver capacity decided the size of the finale (+1 to F-CW4 and F-XW8)913+- from: Tidewater914+- side: creator915+- severity: medium916+- owner (suggested): engine917+- status: open918+919+The first 12ร9 draft of `last-boat-out` ran `tools/solve.mjs --max 2000000`920+out of memory (a 4 GB heap crash, not LIMIT). A compact port reached 13921+million states without a solution. The shipped 10ร7 version needs 261k states922+and about 90 s, with a peak RSS of 1.7 GB. A mutable-state port with compact923+string keys (`/tmp/tw/eng.mjs`, `/tmp/tw/fs.mjs`, differentially tested with924+0 mismatches over 57k random steps) is about 20ร faster and supports925+"solve without rule X" and "solve without sinking a word". Those constraint926+modes are how I proved each level's key edit is necessary.927+**Proposed fix:** adopt a compact-key, mutable-state solver and add928+`--without-rule "<RULE>"` and `--no-word-loss` constraint flags. They answer929+"is my intended idea required?" (F-CW5, F-XW10) directly.930+931+### F-DS1: shell-game's key rule can't be machine-proven932+- from: design (CI setup)933+- side: creator934+- severity: low935+- owner (suggested): content936+- status: resolved (Tidewater reworked `shell-game` to 5ร7 with pinned sentences; `--forbid 'JELLY IS SHELL'` is UNSOLVED after 623,612 states, and the level declares `mustForm`)937+938+`tidewater/shell-game --forbid 'JELLY IS SHELL'` hits LIMIT at 3M states. Per939+the engine audit, the authored words over-approximate alternative rules, and a940+lexical shortcut would be unsound. **Proposed fix:** rework the proof with941+fewer loose words and a smaller open area, so the forbid search terminates.942+Then add `mustForm: ['JELLY IS SHELL']`.943+944+### F-XW14: Proving mustForm cost Crossword its decoy words945+- from: crossword (W6 rework)946+- side: creator947+- severity: medium948+- owner (suggested): engine949+- status: open950+951+`--forbid` has to prove UNSOLVED exhaustively. Each loose word multiplies952+the state space, and words can drift through non-STOP things (a column of953+DEFEAT rocks) into the whole board. In `shove-the-danger`, the three loose954+words (`AND`, `PUSH`, and a decoy `STOP`) hit LIMIT at the 2M default and955+needed about 5M to prove. `pass-it-on` needed 9.5M at 10 by 7. To make the956+proofs fit, I removed the decoy and narrowed both boards (7 by 7 and 8 by 7).957+The decoy was good design: it made the player choose `PUSH` over `STOP`.958+`spend-it-twice` proves at 1,905,020 states, 95% of the default, so it now959+declares `solverBudget: 4000000` for margin. A full pack run takes about 6960+minutes. This is a sibling of F-XW8, F-TW10, and F-DS1.961+**Proposed fix:** make the forbid search prune by word-motion symmetry, or962+by a static dead-word analysis (a word in an edge row or column that can't963+reach any target cell). Also consider a per-level CI budget report that shows964+how close each proof is to its cap.965+966+### F-XW15: mustForm can't express "some crossing rule"967+- from: crossword (`across-and-down`)968+- side: creator969+- severity: low970+- owner (suggested): engine971+- status: open972+973+`across-and-down` teaches "one IS, read across and down". `--forbid 'FLAG IS974+WIN'` solves in 26 steps through a different crossing (`FLAG IS YOU` down,975+then `MIAU IS WIN` across), so the level's real invariant is "some vertical976+sentence shares the IS". `mustForm` only takes exact rule texts, so I declared977+nothing, and the level can't be regression-checked.978+**Proposed fix:** allow structured requirements such as `mustForm: [{ axis:979+'v' }]`, `{ predicate: 'WIN' }`, or `{ sharesWordWith: 'MIAU IS YOU' }`,980+checked against `rule.axis` and `rule.sourceIds`, with the same forbid-based981+proof.982+983+### F-XW16: A precedence change silently broke shipped levels984+- from: crossword (W6 rework)985+- side: creator and player986+- severity: high987+- owner (suggested): engine988+- status: open989+990+W6 (D23) changed STOP versus PUSH, self-DEFEAT, `X IS X`, transform order, and991+stacked parsing. Packs still declare `engine: 1`, and nothing in load or992+solve told me my levels changed. I found out only when the design lead993+mentioned that `carry-the-pair` now hit LIMIT (its old route killed the cat)994+and that `shove-the-danger`'s decoy had changed meaning. A player who had995+installed the old pack would find a proof unwinnable with no explanation.996+**Proposed fix:** bump the manifest `engine` (or add a `rules` semantic997+version) whenever precedence changes. Warn on load when a pack targets older998+semantics. Run every bundled and world pack through `tools/solve.mjs` with its999+`mustForm` in CI, so a semantics change fails loudly.@@ -0,0 +1,277 @@
1+# Tidewater (world bible)2+3+Pack: [`mods/worlds/tidewater.cjs`](../../mods/worlds/tidewater.cjs), id `tidewater`, 7 levels.4+5+World identity is declared in `manifest.world`. `loadMod` accepts the field6+today without validating it (see F-CW8):7+8+```js9+world: { glyph: '๐', tagline: 'The sea keeps whatever you give it.', accent: 'sink' }10+```11+12+## Theme13+14+A coastline at changing tide. The water swallows whatever walks or is pushed15+into it: rocks, words, and cats. Jellies sting whatever is YOU. A crab lives16+on the island you can't reach. Out past the waves, the last boat is waiting.17+18+The emotional line of the world is **giving things to the sea**. First you19+turn the tide off. Then you feed it a sentence you don't need. Then a20+sentence that was hurting you. Then you stop being the cat and become the21+crab. Then you turn the stingers into prizes. Then you let one crab go so the22+others can cross. Finally you turn the jellies into more of yourself and walk23+the whole family into the waves so that one cat reaches the boat.24+25+| Noun | Glyph | Role |26+| --- | --- | --- |27+| `MIAU` | ๐ (`faces: 'left'`, profile) | The player |28+| `WATER` | ๐ | The sea (`SINK`). One cell of water swallows one thing, and is used up doing it |29+| `JELLY` | ๐ชผ | The hazard (`DEFEAT`), and later the raw material for transformation |30+| `CRAB` | ๐ฆ (plain, drawn face-on) | The other identity. It lives where the cat can't go |31+| `SHELL` | ๐ | The goal in most levels (`WIN`) |32+| `BOAT` | โต (plain, no clear profile) | The goal of the finale (`WIN`) |33+| `ROCK` | ๐ชจ | A barrier (`STOP`) whose own sentence becomes bridge material |34+| `REEF` | ๐ชธ | Declared for future levels; unused in 1.0.0 |35+36+I checked the glyphs on Apple Color Emoji against `#0F0E0D` at 58px. All of37+them read. ๐ชผ is dark blue and has the lowest contrast of the set, but it's38+still legible. ๐ชผ and ๐ชธ are newer emoji (Unicode 15 and 14), so older39+Android and Windows 10 devices will show tofu (F-TW6). Only the cat declares40+`faces`. ๐ฆ is drawn face-on, and โต has no reliable heading across fonts.41+42+## Mechanic43+44+Core vocabulary only. The world is about **hazards and identity**, and three45+engine facts carry it:46+47+1. **SINK takes everything in the cell, including the water.** A pushed rock,48+ a pushed word, or a walking YOU all vanish together with one water cell.49+ Every fill costs something. Words are the most common fill, so the world is50+ really about *which sentence can you spare*.51+2. **DEFEAT only removes YOU things.** Words sit on jellies safely, and a52+ transformed jelly stops stinging.53+3. **Identity is a sentence.** `CRAB IS YOU` moves control across the water.54+ `JELLY IS SHELL` turns hazards into goals. `JELLY IS MIAU` turns hazards55+ into more of you, and several YOUs in a line fill water one per turn.56+57+What the engine can't express is the brief's original "become a BOAT to cross58+what MIAU cannot". There's no FLOAT, and SINK doesn't care what you are. The59+world uses "become whoever is already on the other side" instead (F-TW2).60+61+## Levels62+63+Solver: `node tools/solve.mjs mods/worlds/tidewater.cjs --max 2000000`. All 764+levels are SOLVED, with no BYPASS and no TRIVIAL. The full pass takes about65+90s, most of it on `last-boat-out`, with a peak RSS of 1.7 GB. I also used a66+compact port of the turn rules. It's differentially tested against67+`src/engine.js` (57,579 random steps compared, 0 mismatches), and every path68+it finds is replayed on the real engine. I used it to check that the intended69+edit is *necessary*:70+71+- `paper-bridge` and `sink-the-sting` are UNSOLVED if no word may sink.72+- `sideways` is UNSOLVED without `CRAB IS YOU`.73+- `one-for-the-tide` is UNSOLVED if no crab or cat may be lost.74+- `last-boat-out` is UNSOLVED without `JELLY IS MIAU` (147,349 states75+ exhausted).76+- `shell-game` declares `mustForm: ['JELLY IS SHELL']`, and the committed CLI77+ proves it: `node tools/solve.mjs mods/worlds/tidewater.cjs --level78+ shell-game --forbid 'JELLY IS SHELL' --max 3000000` returns UNSOLVED after79+ 623,612 states (F-DS1).80+81+| # | id | Title | Category | Board | Teaches | Solver steps | Flags | Intended insight |82+| --- | --- | --- | --- | --- | --- | --- | --- | --- |83+| 1 | `low-tide` | Low tide | required | 9ร7 | `WATER`, `SINK`, `SHELL` | 10 `DDUURRRRRR` | none | A wall of water crosses the whole board. It only swallows things because `WATER IS SINK` says so. Knock the sentence apart and walk through the sea. |84+| 2 | `paper-bridge` | Paper bridge | required | 10ร7 | `ROCK`, `STOP`, words sink | 9 `DRRRRRRUR` | none | The cat is penned in by rocks, and `WATER IS SINK` is out of reach across the water. Breaking `ROCK IS STOP` frees you, and the loose words from that sentence are exactly what the sea will take as a bridge. |85+| 3 | `sink-the-sting` | Sink the sting | required | 11ร7 | `JELLY`, `DEFEAT` | 13 `DDRRRRRRUUURR` | none | The shell is ringed by jellies across the water. The only spare words are `JELLY IS DEFEAT`, and pushing that sentence into the sea does two jobs in one push: it bridges the water and removes the sting. |86+| 4 | `sideways` | Sideways | required | 11ร7 | `CRAB`, identity swap | 11 `DLLLUUUDDDR` | none | Two waves are too wide to bridge with the one spare word. Push `CRAB` up into `MIAU IS YOU`, and the crab on the island walks to the shell. |87+| 5 | `shell-game` | Shell game | required | 5ร7 | noun-to-noun transformation | 11 `UURDRUURULU` | none; `mustForm: ['JELLY IS SHELL']`, forbid search UNSOLVED (623,612 states) | There are no shells on the board, and both `MIAU IS YOU` and `SHELL IS WIN` are pinned in the right-edge column. `JELLY` and `IS` stand in a column between jellies. Steer the loose `SHELL` round to the right of `DEFEAT` and push it in from the side, so `JELLY IS SHELL` reads downwards: the stingers become the prize. |88+| 6 | `one-for-the-tide` | One for the tide | optional | 11ร7 | several YOUs, sacrifice | 14 `DLUUUURRRRRRRR` | none | Three crabs in a row. After `CRAB IS YOU` they all march together: the first crab fills the first wave, the second crab fills the second, and the third arrives. |89+| 7 | `last-boat-out` | Last boat out | challenge | 10ร7 | a noun as the predicate of a vertical sentence | 26 `UULLDRDLLRRUULUUUDDRRRRRRR` | none | Three waves and one cat. `MIAU IS YOU` is locked in the bottom-left corner, but its `MIAU` can also end a vertical sentence. Drop the `IS` from `JELLY IS DEFEAT` into the gap between `JELLY` and `MIAU` to read `JELLY IS MIAU` downwards. Both jellies become cats. Line the three cats up against the top edge, feed `DEFEAT` and two cats to the sea, and the last cat takes the boat. |90+91+### Why the challenge is non-obvious92+93+- The rule edit reuses a word that already works in another direction. `MIAU`94+ is the subject of the locked horizontal `MIAU IS YOU` and becomes the95+ predicate of the vertical `JELLY IS MIAU`. `JELLY` never moves; only the96+ `IS` does.97+- The count is tight. After the edit, the only sinkable word is `DEFEAT`98+ (`JELLY` and the moved `IS` are pinned to column 0, and `MIAU IS YOU` is99+ locked). With three waves, two cats have to go into the sea.100+- Alignment is the second half. The three cats are in three rows and move in101+ lockstep. The top edge of the board is the tool that lines them up in one102+ row, in the order they'll fall.103+104+## Design notes105+106+- **Pinning rules.** A sentence is fixed in two ways: it's flush in a corner107+ (`MIAU IS YOU` in the bottom-left of `last-boat-out`), it runs down the108+ board edge flush into a corner (both sentences in `shell-game`), or it's109+ flanked by jellies the cat can't stand on (`JELLY IS` in `shell-game`), or it's across the water (every `WATER IS SINK`).110+ Across-the-water pinning reads naturally in this world ("the rule for the111+ sea is on the far shore"), so it costs no extra rows.112+- **Word economy is the difficulty dial.** In every water level, count the113+ words that are spare (not needed for `YOU` or `WIN`, and not pinned) and114+ compare that with the width of the water. `sideways` has two waves and one115+ spare word, so the level forces the identity swap.116+- **Map helper.** The pack draws levels as text maps (`map(id, rows, meta)`)117+ with a legend of one-letter things and uppercase words. Ids come from118+ position (`<level>-<x>-<y>`), with the known caveat in F-CW14.119+- **Blank-row spacing.** This is honoured everywhere except the vertical120+ far-shore sentences in `last-boat-out`, which sit in their own column.121+- **Hints** name an observation, never a sequence: "The sea is not fussy about122+ what you feed it."; "There is no shell on this beach. Yet."; "Three waves123+ and one cat. Try reading MIAU from above."124+125+## Surprising engine behaviour (with minimal reproductions)126+127+All of these were checked on `src/engine.js`. The scripts are in128+`/tmp/tw/repro.mjs` and `/tmp/tw/repro2.mjs` (scratch).129+130+1. **Ghost rule after a word sinks (F-TW1).** Rules are parsed *before*131+ contacts resolve, and they aren't re-parsed after removals. If a sentence is132+ still whole on the turn one of its words sinks, the rule stays in133+ `state.rules`, and it keeps working for the next move. A blocked move134+ doesn't refresh rules either, so the ghost lasts until some move is135+ accepted.136+137+ ```js138+ // 6x3. Words: MIAU IS YOU (row 0, x 0-2), WATER IS SINK (row 0, x 3-5),139+ // ROCK IS STOP (row 1, x 1-3). Things: MIAU (0,1), WATER (4,1), ROCK (1,2).140+ g.step(1, 0); // pushes ROCK IS STOP right; STOP enters the water and sinks141+ // state.rules still contains 'ROCK IS STOP'142+ g.step(0, 1); // MIAU steps down into the rock: rejected, reason 'stop'143+ ```144+145+2. **Transformation resolves before contact.** In one step you can push the146+ last word of `JELLY IS SHELL` into place and walk onto a jelly. The jelly is147+ a shell before DEFEAT is checked, so you survive (and win if148+ `SHELL IS WIN`), even though `JELLY IS DEFEAT` is still on the board.149+3. **Transformation chains advance one link per accepted move.** With150+ `ROCK IS SHELL` and `SHELL IS JELLY`, the rock is a SHELL at reset and151+ becomes a JELLY after the next step (also reported in F-XW6).152+4. **SINK and DEFEAT both beat WIN in the same cell.** `SHELL IS WIN AND SINK`:153+ walking onto the shell removes both and doesn't win. `JELLY IS WIN` while154+ `JELLY IS DEFEAT` is active: touching the jelly kills you first, which is a155+ nice trap in `last-boat-out`.156+5. **Nothing can start on water.** Reset resolves contacts, so a boat or jelly157+ placed on a `WATER IS SINK` cell is gone before the first frame, with no158+ warning (F-TW3). "Moored" and "floating" things can't be authored.159+6. **Lockstep YOUs fill water in order.** The front mover is planned first. A160+ line of YOUs walking into water loses one per wave, and the rest walk on161+ over the filled cells. `controlLost` only fires when the last YOU is gone.162+7. **A mod palette entry for a built-in noun used to turn off its facing.**163+ `MIAU: '๐'` as a plain string never flipped (F-TW4). The working-tree164+ `src/render/facing.js` now inherits the built-in facing when the glyph165+ matches. Tidewater declares `{ glyph: '๐', faces: 'left' }` anyway.166+167+## Crumbling tiles (proposal)168+169+The engine team asked for concrete desired interactions for **tiles that170+crumble after one step**. Tidewater is the natural home for them: driftwood171+that sinks after you cross, and sandbars the tide takes back. Everything172+below is **provisional**. The drafts are not in the `.cjs`, and the user may173+define different semantics.174+175+### The design default, and one challenge to it176+177+Design's default is: a rule-driven property (`DRIFT IS FRAIL`); the tile178+crumbles when its occupant leaves; any mover counts; it leaves an empty cell;179+a level can place `WATER` under it to make the hole deadly; the counter is 1180+only.181+182+I agree with all of it **except "place WATER under it"**. It can't work in183+the current contact model. A cell holding `DRIFT` and `WATER` while184+`WATER IS SINK` is active is a two-occupant cell with a SINK thing, so reset185+destroys both before the first frame (behaviour 5 above). Making it work would186+need a floor layer, meaning FRAIL things shield their cell from contact. That187+adds precedence rules every other property would have to respect.188+189+**Counter-proposal: a crumble *becomes* its leftover.** Each frail object may190+carry `leaves: '<NOUN>'` in level data. When it crumbles, it is transformed191+into that noun, keeping its id. With no `leaves`, it's removed. This reuses192+the transform event and tween. The deadly hole is simply `leaves: 'WATER'`,193+and it's only as deadly as `WATER IS SINK` currently is, so players can still194+reason about it with sentences.195+196+### Semantics, decided by the scenarios197+198+| Question | Decision | Why (scenario) |199+| --- | --- | --- |200+| On step on, or step off? | **Step off.** Standing is safe. | Draft A: standing on the last plank while pushing a word ahead is the core move. Crumbling on entry makes every frail tile a SINK in disguise. |201+| When in the turn? | **At end of movement, before contacts:** move, rules, transform, crumble, contacts. A frail tile crumbles if a non-frail thing stood on it at the start of the turn and nothing stands on it now. | Draft A: in a push, the pushed word leaves plank 1 in the same step that the cat enters it. Evaluating per mover would drop the cat. End-of-turn evaluation makes a **handover** safe. |202+| YOU only, or any mover? | **Any mover** (things and words). | Draft A needs a word to cross. Draft B uses a pushed rock to spend a sandbar deliberately. Counting only YOU would make pushed things weightless, which contradicts SINK, where pushed things do count. |203+| What's left behind? | **Nothing by default. `leaves` makes it a noun** (usually `WATER`). | See above. Both drafts use `leaves: 'WATER'`. |204+| Rule or level data? | **A rule: `DRIFT IS FRAIL`.** The leftover is level data. | Draft A's alternative solution is breaking `DRIFT IS FRAIL` so the bridge stays. That's the Baba answer, and it only exists if crumbling is a sentence. |205+| Counter above 1? | **Not as a counter. Express it as a leftover chain.** | Draft B: `SAND` leaves `SHOAL`, and `SHOAL` leaves `WATER`, which gives two crossings. The remaining count is readable as a *different glyph* (๐จ then ๐ซ), because design forbids decorations on sprites (no crack pips or `รN` on a tile). It also needs no new state. |206+207+### Undo, reset, sharing, and the solver208+209+- With the leftover-chain design, **all crumble state is the noun**, which the210+ snapshot, undo, reset, sharing, and `tools/solve.mjs` (`keyOf` hashes id,211+ x, y, and noun) already cover.212+- If the engine ever adds a numeric counter, it has to live on the object213+ record in `state.objects` so that `structuredClone` history carries it.214+ `reset()` must rebuild it from level data (today it drops unknown fields;215+ see F-CW17), and the solver key must include it. Otherwise BFS merges216+ distinct states and reports false UNSOLVED or wrong shortest paths.217+- Shared packs need `leaves` validated by `loadMod` (it must be a noun token)218+ and preserved by `exportPack`.219+220+### Draft A: "One plank each" (DRAFT, not in the pack)221+222+`d` is `DRIFT` with `leaves: 'WATER'`. `DRIFT IS FRAIL` is new vocabulary.223+224+```js225+// 11x7. The shell is across three planks. WIN is on the near shore and must226+// cross too.227+[228+ 'MIAU IS YOU . . w w w . . .',229+ '. . . . . w w w . . .',230+ '. m . WIN . d d d . s .',231+ '. . . . . w w w . . .',232+ 'DRIFT IS FRAIL . . w w w SHELL IS .',233+ '. . . . . w w w . . .',234+ '. . . . . w w w WATER IS SINK',235+]236+```237+238+Intended solution: push `WIN` right across the planks. Each plank that `WIN`239+leaves is entered by the cat in the same step, which is a handover, so the240+planks hold. Then the cat steps off plank 3, it becomes water, and the bridge241+is gone behind you. Carry `WIN` into `SHELL IS _` from above and touch the242+shell. Alternative: break `DRIFT IS FRAIL` first, and the planks stay. This243+tests step-off, end-of-turn evaluation, any mover counting, and the rule form.244+245+### Draft B: "Two tides" (DRAFT, not in the pack)246+247+`a` is `SAND` with `leaves: 'SHOAL'`, and `h` is `SHOAL` with248+`leaves: 'WATER'`. The rule is `SAND AND SHOAL IS FRAIL`.249+250+```js251+// 12x7. The IS you need is on the island. You go out and come back, so each252+// sandbar is crossed twice.253+[254+ 'MIAU IS YOU . . w w w . . . .',255+ '. . . . . w w w . . . .',256+ '. m . . . a a a . IS . .',257+ '. . r . . w w w . . . .',258+ 'SAND AND SHOAL IS FRAIL w w w . . .',259+ '. . . . . w w w . . . .',260+ 'SHELL . WIN . s w w w WATER IS SINK .',261+]262+```263+264+Out: sand becomes shoal. Back, pushing `IS` ahead: shoal becomes water behind265+you. You can't go out a third time. The trap: if you push the rock `r` across266+first "to test the bridge", it spends one crossing (any mover counts), and267+you're stranded on the island. The glyph change from sand to shoal is the268+visible counter.269+270+### Open questions for the engine271+272+1. Does a frail tile crumble when it's *transformed away* (for example273+ `DRIFT IS ROCK`)? I suggest no: only departures count.274+2. Does a word standing on a plank count as an occupant for handover? I275+ suggest yes, for consistency with "any mover".276+3. `FRAIL` on a YOU thing (`MIAU IS FRAIL`): I suggest it has no effect,277+ because FRAIL only triggers on things that are left, not on the mover.