clone

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

main
miau-is-true / docs / worlds / friction.md
friction.md 48.4K source

Friction log (creator and player)

Triage (design, 2026-09-23, after Crate Works and Crossword)

Theme Entries Resolution
Authoring format F-CW1, F-XW1, F-CW14 Engine ask W1: an ASCII board() helper with a legend and stable ids in @miau/engine
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
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
Sokoban goal F-CW10 Engine ask W4: core GOAL property (spec in docs/worlds/crate-works.md)
Gates and switches F-CW17 Engine ask W5: semantics plus 3 drafts in crate-works.md
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
Validation and metadata F-CW8, F-CW9, F-XW11, F-XW7, F-CW16 Engine ask W7
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)
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)
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
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)
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)
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)

Append-only. Design triages each entry into the worlds inbox (engine asks) or design.md (design changes).

### F<n>: <short title>
- from: <world or person>
- side: creator | player
- severity: blocker | high | medium | low
- owner (suggested): engine | design | content
- status: open

What happened, with the exact file, level, or command.
**Proposed fix:** …

F1: Existing bundled packs are bypassable

  • from: design (solver pass)
  • side: player
  • severity: high
  • owner (suggested): content
  • status: open

node tools/solve.mjs on the shipped mods: every level in garden-basics, word-lab, and moon-cat is winnable without moving a word (BYPASS), and shared-control and moon-cat-intro are TRIVIAL (3 and 5 steps). Core two-things-can-win is also a BYPASS (6 steps, RRRRRR). The packs don't yet teach the rule system they're named after. Proposed fix: rework these packs with the solver (content), and add the solver to CI as an authoring check (engine): BYPASS becomes a warning unless the level declares allowBypass: true.

F2: No solver or bypass check for authors

  • from: design
  • side: creator
  • severity: high
  • owner (suggested): engine
  • status: open (a stopgap is in tools/solve.mjs)

A creator can't tell whether a level is solvable, or skippable, without playing it exhaustively. Proposed fix: adopt tools/solve.mjs, or an equivalent, as a supported npm run solve -- <pack>, and surface its result in the Workshop as "verified" once the level editor exists.

F-XW1: Coordinates by hand; no grid-based level helper

  • from: crossword (world-builder)
  • side: creator
  • severity: medium
  • owner (suggested): engine
  • status: open

@miau/engine only offers word(text, x, y) and object(noun, x, y), so a 12 by 8 board means counting 30 or more coordinates by hand. It's easy to put a word one cell off and silently break a crossing. For mods/worlds/crossword.cjs I wrote a 20-line grid(id, rows, meta) helper inside the pack. Rows are whitespace-separated cells: . for empty, m r f w for things, uppercase for words, and A+B for stacks. It also rejects ragged rows. Every other world will write its own version. Proposed fix: add grid(rows, { legend, id }) to @miau/engine, using the same notation (stacks with +, ragged rows rejected, ids derived from <level>-<token>-<x>-<y> so they stay stable).

F-XW2: Sentences can only be pinned by geometry, and players can't see it

  • from: crossword
  • side: creator and player
  • severity: high
  • owner (suggested): design (presentation), engine (optional primitive)
  • status: open

Every "given" sentence in Crossword (for example MIAU IS YOU in company, ROCK IS DEFEAT in shove-the-danger, FLAG IS WIN in pass-it-on) is made unbreakable by placing it in the top or bottom row flush against a side edge. That's the only way to get an unbreakable rule without custom rules. It has two costs. (1) Creators must know the trick, and it also traps words: an IS pushed under a pinned word in an edge column can never come out, which silently softlocked my first draft of spend-it-twice. (2) Players can't tell a pinned sentence from a loose one until they try to push it. My company hint has to say "stuck to the edge". Proposed fix: design: draw an edge-pinned or immovable word with a distinct cue (for example, a flat lip on the side it's pinned against) and show a short shake plus the blocked sound when a push fails at a board edge. Engine: an optional fixed: true on word records, rendered with the same cue, so authors don't have to rely on corners.

F-XW3: Stacked words are hard to read and draw two bars

  • from: crossword (carry-the-pair)
  • side: player
  • severity: high
  • owner (suggested): design (with engine for spans)
  • status: open

carry-the-pair hinges on noticing that WIN and DEFEAT share one cell and can't be separated. Design's stack treatment (a ±4° fan and 9% offset, with a ×N badge only for 3+ items) is subtle at the 22px minimum cell. The board never says "these two move together". Also, state.sentences returns two same-axis spans of equal length for FLAG IS WIN+DEFEAT (FLAG IS WIN and FLAG IS DEFEAT). Neither contains the other, so the renderer draws two bars over the same cells. Proposed fix: design: always badge word stacks (×2) and fan them wider when a stack sits in a sentence. The ledger should show a stacked predicate as a single WIN+DEFEAT chip. Engine: merge spans that cover the same cells into one span with stackedIds.

F-XW4: AND is greedy and never backtracks, so stacked connectives read inconsistently

  • from: crossword (parser probing)
  • side: creator
  • severity: medium
  • owner (suggested): engine
  • status: open

src/engine.js parseRules consumes AND noun pairs before it looks for IS, and it never retries. Repro (text grid): ROCK AND+IS FLAG gives no rule, but ROCK AND+IS WIN gives ROCK IS WIN. MIAU AND+ROCK IS YOU gives only ROCK IS YOU, so MIAU is dropped. I avoided building on this because it looks accidental. Proposed fix: decide the rule. Either the parser tries every reading of a stacked cell (backtracking), or authoring validation warns when a stack mixes a connective with a noun or with IS. Document the choice in docs/modding.md.

F-XW5: Rule precedence differs from the reference game and isn't documented

  • from: crossword
  • side: creator and player
  • severity: medium
  • owner (suggested): engine (document), design (decide)
  • status: open

Found while building pure-grammar levels: (a) ROCK IS STOP AND PUSH is a wall, because planMove checks STOP before PUSH. In the reference game, PUSH wins. (b) MIAU IS YOU AND DEFEAT doesn't destroy the cat, and MIAU IS YOU AND WIN+DEFEAT wins, because contacts only resolve in cells with 2 or more occupants. In the reference game, the carrier is destroyed. (c) ROCK IS ROCK doesn't stop ROCK IS FLAG. In the reference game, X IS X blocks transformation. shove-the-danger (the STOP decoy) and carry-the-pair (the self-win with DEFEAT) depend on (a) and (b). If the engine changes, those two levels change. Proposed fix: publish a precedence table in docs/modding.md (STOP vs PUSH, self-contact, X IS X), and mark each behaviour intentional or a bug. Worlds can then cite it.

F-XW6: Transformation conflicts and chains are invisible and order-dependent

  • from: crossword
  • side: creator and player
  • severity: medium
  • owner (suggested): engine
  • status: open

(a) With ROCK IS FLAG and ROCK IS WALL both active, the winner is whichever word comes first in the level's objects array. Swapping two rows of an otherwise identical level swaps the result. (b) With ROCK IS FLAG and FLAG IS WALL, a rock is a FLAG after reset and becomes a WALL on the next accepted move, one link per turn. The level loads in an intermediate state. (c) MIAU IS YOU AND ROCK turns the cat into a rock at reset, so the level starts with nothing as YOU even though the board says MIAU IS YOU. Proposed fix: resolve transformations to a fixed point each turn, with cycle detection. Pick conflicts deterministically in reading order (top to bottom, left to right), not object order. Have validation warn when a level starts with no YOU.

F-XW7: The default noun glyphs are hidden in src/main.js

  • from: crossword
  • side: creator
  • severity: medium
  • owner (suggested): engine
  • status: open

The brief for this world was "use only nouns with default emoji". The only list is a glyphs object literal on line 67 of src/main.js (MIAU, CAT, BABA, FLAG, ROCK, WALL, WATER, GRASS, STAR, KEY, DOOR, KEKE, THORN, GEM). It isn't exported, documented, or reflected in vocabulary, and nothing tells an author that a noun without a palette entry falls back to a text label. Proposed fix: move the table to a shared module (for example src/glyphs.js), export it, list it in docs/modding.md, and have loadMod warn (not fail) when a noun has neither a palette entry nor a default glyph.

F-XW8: Solver capacity limits challenge design

  • from: crossword (spend-it-twice)
  • side: creator
  • severity: high
  • owner (suggested): engine
  • status: open

tools/solve.mjs builds a fresh createGame (with structured clones) for every expansion. A 12 by 9 draft of spend-it-twice with three loose words hit LIMIT after 2000001 states in 2m39s. I had to shrink it to 9 by 7 to verify it (35 steps, 41s). A full pass over this 7-level pack at --max 2000000 takes about 2m45s. So the solver, not the design, decided the size of the world's hardest level. Proposed fix: step a single mutable state with an undo stack instead of createGame per node, use a compact integer state key, and add an optional A* mode (for example, distance of loose words to target cells) or a push-level macro search for long levels.

F-XW9: Standing on a thing and then writing its rule wins

  • from: crossword (carry-the-pair first draft)
  • side: creator
  • severity: medium
  • owner (suggested): design (decide), engine (solver flag)
  • status: open

The first draft had FLAG IS WIN+STOP. The solver broke the sentence, walked onto the flag, and rebuilt FLAG IS WIN+STOP vertically under the cat. That won in 16 steps, because STOP never ejects an occupant. It's a legitimate reference-game trick, but it defeats any "the goal is STOP" framing, and BYPASS doesn't catch it (words did move). I fixed it by using DEFEAT instead. Proposed fix: have the solver print the rules active at the winning step and flag WON-WHILE-OVERLAPPING when the win came from a rule formed while YOU already shared the cell, so authors can decide.

F-XW10: The solver shows one route and can't check the intended one

  • from: crossword (down-to-miau)
  • side: creator
  • severity: medium
  • owner (suggested): engine
  • status: open

down-to-miau was meant to be solved with ROCK IS MIAU (a transformation). The shortest route was ROCK / IS / YOU down onto the shared YOU, and I only found that by writing a replay script (/tmp/xw/replay.mjs) to print the rules added on each step. I then added a wall (a black square) to block that column. Proposed fix: add solver flags --explain (print rules added or removed along the path, and the board at the end), --require "ROCK IS MIAU" (the route must form this rule), and --forbid "ROCK IS YOU" (report whether a route avoiding it exists).

F-XW11: Validation ignores world metadata and the quality-bar constraints

  • from: crossword
  • side: creator
  • severity: low
  • owner (suggested): engine
  • status: open

loadMod accepts manifest.world (any shape), category (any value), introduces and reuses (anything), and boards up to 128 by 128. The worlds README requires boards of at most 14 by 10 and category in required | optional | challenge, but a typo such as category: 'challange' or a 15-wide board passes silently. Proposed fix: validate manifest.world (glyph of at most 16 chars, tagline of at most 80 chars, accent in the six property tokens) and category. Emit non-fatal warnings for boards over 14 by 10 and for introduces that isn't a string array.

F-XW12: With crossing words, the ledger doesn't show which sentence a word is in

  • from: crossword (across-and-down, pass-it-on)
  • side: player
  • severity: medium
  • owner (suggested): design
  • status: open

In a crossing, the shared IS is fully opaque as long as either sentence is active. If the player breaks the vertical sentence, the IS still looks active, and only the dimmed FLAG shows that anything changed. In a chain like ROCK IS FLAG IS WIN, the middle FLAG belongs to two rules. The ledger lists rules but doesn't point back to their tiles or axis. Proposed fix: hovering or focusing a ledger rule highlights its source tiles (rule.sourceIds already exists) and draws its bar brighter. Show a small axis mark (across or down) on ledger rules that came from a vertical span.

F-XW13: SINK silently deletes words

  • from: crossword (parser probing)
  • side: player
  • severity: low
  • owner (suggested): design
  • status: open

A word pushed into a cell with a SINK thing is destroyed along with the thing (MIAU IS YOU / ROCK IS SINK / m STOP r, step right: the STOP word and the rock are gone). In a pure-grammar level, that can remove a word the solution needs, and the only sign is the missing tile. Proposed fix: show a destroyed word with its own effect (the letters scattering) and a ledger note like STOP IS GONE, so the player knows to undo.

F-CW1: No map format; every level is hand-counted coordinates

  • from: Crate Works
  • side: creator
  • severity: high
  • owner (suggested): engine
  • status: open

Seven boards of 40 to 110 entities each would have meant writing hundreds of word('IS', 8, 3, 'x-is') calls and counting columns by hand. Crate Works defines its own board(rows, meta) helper at the top of mods/worlds/crate-works.cjs: one character per cell, a legend (# WALL, $ CRATE, ~ GAP, uppercase letters for words), and row-width checks with readable errors ("row 2 is 11 wide, expected 13"). That's about 25 lines every pack author will rewrite. Proposed fix: add map(rows, legend, meta) to @miau/engine. Legend values are 'WORD', { noun }, or an array for stacks. Include row-width and unknown-character errors that name the level, column, and row, and a documented default legend so packs and bug reports read the same way.

F-CW2: Rule words can't be fixed, so every level spends rows sealing an "office"

  • from: Crate Works
  • side: creator
  • severity: high
  • owner (suggested): engine
  • status: open

Every rule a level depends on (WALL IS STOP, GAP IS SINK, TRUCK IS WIN) can be broken by the player unless it's physically sealed away. The only tools are a full wall row, which needs WALL IS STOP and so is circular, or packing row 0 so the board edge stops upward pushes. Two of my drafts fell to this. load-order v1 was solved in 15 by pushing STOP up out of WALL IS STOP from the room below, which made the walls passable. That's a word move, so the solver didn't flag BYPASS. spend-it-last v1 was solved by sinking a word into a row-0 gap and then pushing GAP IS SINK sideways. Proposed fix: add a per-word fixed: true flag. The engine treats it as immovable and the renderer draws it bolted: no lip, a flat tile, a small rivet in the gutter. Also add a documented "sealed rules" slot outside the board for rules that should never be editable (a ledger-only rule).

F-CW3: Solid terrain requires a breakable rule

  • from: Crate Works
  • side: creator
  • severity: medium
  • owner (suggested): engine
  • status: open

A WALL does nothing unless WALL IS STOP is on the board, so every level pays three tiles plus sealing (F-CW2) just to have walls. Players also have to read that sentence in every level even though it is never the puzzle. Proposed fix: allow level-declared terrain nouns that are STOP implicitly: terrain: ['WALL']. They're shown in the ledger as a greyed built-in rule and can still be overridden by a written rule if a level wants that.

F-CW4: tools/solve.mjs is too slow and runs out of memory on normal board sizes

  • from: Crate Works
  • side: creator
  • severity: high
  • owner (suggested): engine
  • status: open

The solver builds a new createGame and runs structuredClone for every node, about 25k states/s. spend-it-last (14×5) needed --max 2000000 and 70 s. A 13×9 room with four loose words hit the limit, and my faster re-implementation ran out of memory at 4 GB storing full states in the BFS frontier. I had to write a compact simulator (/tmp/cw/sim.mjs, about 30× faster, differentially tested against src/engine.js with random walks) to iterate at all. Proposed fix: give the engine a stepState(compactState, dir) fast path with no events and no clone. Store parent pointers instead of path strings. Hash with typed arrays. Add --replay <path> to print frames, because I also had to write that.

F-CW5: The solver can't say whether the intended rule is needed

  • from: Crate Works
  • side: creator
  • severity: medium
  • owner (suggested): engine
  • status: open

BYPASS only catches "no word moved". Unintended rule edits pass silently. The first crates-move was won with CRATE IS WIN (WIN pushed onto CRATE IS PUSH) instead of TRUCK IS WIN, and load-order v1 was won by breaking WALL IS STOP. I added --forbid "RULE" to my own solver to prove that GAP IS CRATE and CRATE IS YOU are necessary. Proposed fix: add --forbid "X IS Y" and --require "X IS Y" to tools/solve.mjs, and optional level metadata intended: { rules: [...] } that the checker verifies (unsolvable without them). Also print the rule set at the moment of winning, which exposes most accidental shortcuts at a glance.

F-CW6: Mods can't declare their own terrain slab; the WALL palette glyph is silently ignored

  • from: Crate Works
  • side: creator
  • severity: medium
  • owner (suggested): engine
  • status: open

src/render/board.js draws a slab only for nouns in options.wallNouns, which defaults to ['WALL'], and nothing passes that option. My palette entry WALL: '🧱' is ignored without a warning, and a themed noun like SHELF or RACK would render as a small emoji instead of terrain. Proposed fix: a palette flag { glyph, terrain: true }, or manifest.terrain: ['SHELF'], passed through to wallNouns. The loader should warn when a palette entry will never be drawn.

F-CW7: A wall that isn't STOP still looks like solid terrain

  • from: Crate Works
  • side: player
  • severity: medium
  • owner (suggested): design
  • status: open

The slab rendering depends on the noun, not the rules. If WALL IS STOP is broken, or a level makes walls PUSH, the board still shows solid racking, and the player learns that walls are passable only by bumping into them. Crate Works avoids this by always sealing WALL IS STOP (F-CW2), but other worlds will hit it. Proposed fix: draw terrain slabs only while the noun is STOP. Otherwise draw a hatched outline (passable) or a slab with a lip (pushable).

F-CW8: manifest.world is accepted with no validation

  • from: Crate Works
  • side: creator
  • severity: low
  • owner (suggested): engine
  • status: open

I added world: { glyph, tagline, accent } per D20. loadMod accepts any shape there (a typo such as accent: 'pushh' or an 200-character tagline passes), so mistakes would only show up at render time, if at all. Proposed fix: validate world.glyph (1 to 16 characters), tagline (80 characters or fewer, no U+00B7), and accent (one of you|push|stop|win|sink|defeat) in validateMod, with the same readable error lines as other fields.

F-CW9: Level metadata (category, introduces, reuses) has no schema

  • from: Crate Works
  • side: creator
  • severity: low
  • owner (suggested): design
  • status: open

The README requires category and introduces, but nothing validates them, and there's no convention for lessons that aren't a single token. I used strings like 'sinking words', 'shared YOU', and 'noun transform' alongside tokens like 'GAP'. A tool that builds a curriculum view can't tell these apart. Proposed fix: validate category against required|optional|challenge. Split introduces into words: [...] (tokens, checked against the level) and ideas: [...] (free text). Document reuses.

F-CW10: No way to express a real Sokoban goal ("every crate on a dock")

  • from: Crate Works
  • side: player
  • severity: high
  • owner (suggested): engine
  • status: open

WIN only fires when a YOU shares a cell with a WIN thing, so multi-box levels can't have a goal that depends on where the boxes are. Crate Works works around it (holes that eat boxes, pushing a PUSH AND WIN truck onto a parked YOU, crate-as-YOU), but the genre's core satisfaction, the last box clicking home, isn't available. Proposed fix: a core property GOAL (DOCK IS GOAL). The level is won at end of turn when a YOU exists, at least one GOAL thing exists, and every GOAL thing shares its cell with another PUSH entity (words count). Full spec, and two prototype-verified draft levels, are in docs/worlds/crate-works.md under "Goal feature spec".

F-CW11: A sunk word takes its rule away with no announcement

  • from: Crate Works
  • side: player
  • severity: medium
  • owner (suggested): design
  • status: open

In spend-it-last, load-order, and night-shift, the key event is a word entering a gap: the word, the gap, and possibly a rule vanish in one turn. The ledger just loses a line. A player who sank PUSH too early doesn't learn why the crate stopped moving. Proposed fix: when a destroyed entity is a word that was in an active sentence, show a short float of the lost rule, struck through (CRATE IS PUSH with a line through it), and play a distinct sink sound for words. The destroyed event already carries text.

F-CW12: Lockstep YOUs give no feedback about who was blocked

  • from: Crate Works
  • side: player
  • severity: medium
  • owner (suggested): design
  • status: open

in-step and back-it-up depend on one YOU being held still by a wall while the other moves. Right now the blocked YOU just doesn't move. That's the most important information in the level, and it's shown by nothing happening. Proposed fix: a per-entity bump for every YOU whose move was refused, even when another YOU moved and the turn was accepted. The engine returns only the first blocked, so settled() should return blocked: [] for every refused YOU.

F-CW13: Crates without PUSH look exactly like pushable crates

  • from: Crate Works
  • side: player
  • severity: medium
  • owner (suggested): design
  • status: open

In night-shift, GAP IS CRATE turns 13 gaps into crates at once. Because CRATE IS PUSH was just dismantled, they're walk-through decoration, but they look identical to the pushable crate from level 1, so the solution looks impossible until the player walks into one. The mass transform also needs a strong cue. Proposed fix: give things with no active properties a subtle "inert" treatment (for example 70% opacity or no ground shadow). When a transform changes more than about 4 entities at once, use a staggered pop.

F-CW14: Position-derived object ids change when the map is edited

  • from: Crate Works
  • side: creator
  • severity: low
  • owner (suggested): engine
  • status: open

To avoid id boilerplate, my board() helper derives ids from the level id and starting cell (night-shift-3-4). Moving one tile renames it. That's harmless today, but anything keyed by entity id (replays, the facing map, future gate state) would silently break across pack versions. Proposed fix: document that replays are move lists only, and state which features, if any, persist entity ids. If some do, have the map helper assign ids by reading order per noun (crate-1, crate-2), which survives most edits.

F-CW15: 14×10, sealed rule rows, and blank-row spacing compete for space

  • from: Crate Works
  • side: creator
  • severity: low
  • owner (suggested): design
  • status: open

A sealed rule office costs 2 rows (words and walls), or 4 with the "blank row between stacked sentences" rule. On a 10-row board that leaves 6 rows for the puzzle. load-order ends up with two rule rows separated only by a wall row, and spend-it-last packs four sentences into one row with single-wall separators. Proposed fix: a sealed-rules strip outside the board (see F-CW2), or count sealed rows separately from the 14×10 limit.

F-CW16: The engine changed under a live authoring session

  • from: Crate Works
  • side: creator
  • severity: low
  • owner (suggested): engine
  • status: open

Partway through, src/engine.js gained uncommitted automata support and the solver was updated. Core semantics didn't change, but I only found that out by diffing and re-running a differential test. Proposed fix: keep a short ENGINE-CHANGES.md or version note that says whether core turn resolution changed. Have the solver print the engine version it ran against.

F-CW17: The engine snapshot has no per-entity mechanism state (gates and switches)

  • from: Crate Works
  • side: creator
  • severity: medium
  • owner (suggested): engine
  • status: open

The gates-and-switches proposal needs a gate to remember open (a jam depends on history), and object records need a channel. Today reset() rebuilds objects from a fixed set of fields, so extra fields are dropped, and object() has no options argument. Proposed semantics, and three prototype-verified scenarios, are in docs/worlds/crate-works.md under "Gates and switches (proposal)". Proposed fix: let object records carry validated extra fields (channel), keep open in state.objects so undo and share just work, and include it in the solver hash.

F-CK1: An undeclared property word silently becomes a noun and transforms things

  • from: Clockwork
  • side: creator
  • severity: blocker
  • owner (suggested): engine
  • status: open

The parser treats any word that isn't IS, AND, or a known property as a noun. On the shipped engine, the draft level key-and-door (/tmp/clockwork-proto/clockwork-pairs.cjs) contains DOOR IS SHUT. It loads without a warning, and at reset every door turns into a SHUT thing with no glyph. The door stops being STOP, so the lock is gone. My first scratch test did the same with GEAR IS MOVE: every gear became a MOVE thing. A creator who misspells a property, or uses one from a newer build, gets a level that quietly plays wrong. validateLevel also accepts { noun: 'TICK' } in a level that declares TICK as an automaton property. Proposed fix: at load time, warn about any predicate word that isn't a known or declared property and isn't a noun used by the pack's objects, palette, or vocabulary. The warning should say something like "SHUT isn't a thing in this pack. Did you mean a property?" Also reject an object whose noun is a declared property word. See ask 1 in docs/worlds/clockwork-api.md.

F-CK2: New level features are silently ignored by builds that don't know them

  • from: Clockwork
  • side: creator
  • severity: high
  • owner (suggested): engine
  • status: open

level.automata shipped without a schema bump, and mods.js passes unknown level keys through. Take a pack that uses automata, or the proposed pairs, or even a typo like automaton. It loads on a build that doesn't know the field, and its property words turn into nouns (F-CK1). The only version gate, manifest.engine, is still 1. Proposed fix: add manifest.features: ['automata', 'pairs']. The loader rejects any feature it doesn't know with the player line "This pack needs a newer Miau." Validation also warns about unknown level keys. See ask 8 in clockwork-api.md.

F-CK3: A player has no way to learn what a custom property does

  • from: Clockwork
  • side: player
  • severity: high
  • owner (suggested): design
  • status: open

TICK is a word no player has seen before. The stage draws its tile and a next-step arrow on each actor, but nothing says "this thing repeats its steps, one for each of your turns". Clockwork puts a one-sentence explanation in each level hint, but hints nudge. They shouldn't have to define vocabulary. Proposed fix (design): the first time a level shows an unfamiliar property tile, show a small card: the tile, its one-line describe, and a short demo of the pattern. After that, the line shows under the ledger entry. Proposed fix (engine): accept a pack-level glossary, properties: { TICK: { accent, describe } }, where accent is a property colour token and describe is at most 80 characters. Clockwork already declares it, and today's loader ignores it.

F-CK4: The renderer hard-codes the custom property list

  • from: Clockwork
  • side: creator
  • severity: medium
  • owner (suggested): engine
  • status: open

src/render/board.js hard-codes its own PROPERTIES set, which now includes TICK, and colours cues with colors.TICK. The engine takes property words from level.automata[].property, but the renderer doesn't. A pack that names its property WIND or SPIN gets a noun-shaped tile with no chamfer and no cue colour, so it reads as a noun. Proposed fix: derive the property set and cue colour from the level's declared properties and the glossary accent (F-CK3), and remove the hard-coded word.

F-CK5: Players see only the next beat, and nothing at all for a paused clock

  • from: Clockwork
  • side: player
  • severity: medium
  • owner (suggested): design
  • status: open

drawAutomataIndicators draws one arrow for the next step, and only while the rule is active. In wind-it-up the player can't preview what the mouse will do before winding it. In escapement (29 steps) the plan depends on where the mouse will be 4 to 6 beats from now. Two facts are also invisible: a paused clock keeps its phase, and handing TICK to another noun keeps that phase too. Proposed fix: add a pattern strip for each automaton (in the ledger or a HUD row) that shows every step as a small arrow or pause mark, highlights the current beat, and stays visible, dimmed, while paused. Keep the on-sprite arrow for the next beat only.

F-CK6: A moving hazard can't guard anything, because contacts only run at end of turn

  • from: Clockwork
  • side: creator
  • severity: medium
  • owner (suggested): design
  • status: open

Contacts resolve once, after the automata beat. A cat can step into a DEFEAT cog's cell on the turn the cog moves away, swap cells with it head-on, or let a STOP patroller walk onto it and then leave. Every patrol-guard level I drafted (stop-the-clock in three layouts) had a timing bypass or blew up the solver. The same rule is a feature elsewhere: a thing that is both YOU and TICK moves twice and skips the cell in between, which is what double-time uses to cross a cog line. Proposed fix: document the "end-of-turn contact" rule and its two consequences (swap-through, and double moves skipping cells) in docs/modding.md next to the automata schema. If guards matter later, the engine could add an opt-in contact: 'each-move' per automaton. That needs a spec, so don't add it silently.

F-CK7: The automata spec, the loader, and the engine disagree

  • from: Clockwork
  • side: creator
  • severity: medium
  • owner (suggested): engine
  • status: open

Three mismatches:

  • openspec/changes/add-rule-driven-automata/specs/.../spec.md describes one automaton with step IDs and property additions and removals. The code implements many automata with move and wait steps.
  • The engine accepts 'U'/'W' shorthand steps, but mods.js rejects them.
  • The engine's MAX_AUTOMATA is 32, and so is the loader's, but only the loader rejects a phase beyond the pattern.

I authored against the code, and I only knew which one was true by reading both files. Proposed fix: update the spec to the shipped model, and have the loader and engine share one validator, or at least one table of limits and accepted forms.

F-CK8: A function anywhere in a pack gives an unhelpful load error

  • from: Clockwork
  • side: creator
  • severity: low
  • owner (suggested): engine
  • status: open

My first behaviour-API draft exported properties: { MOVE: { run(view, self) { ... } } }. loadMod fails with run(){} could not be cloned., because normalizeMod runs structuredClone over the export. Packs are data-only by design (URL links and the pack library store no code), but the message doesn't say so. Proposed fix: before cloning, walk the export for functions and report "levels[2].automata[0].pattern: packs are data only; functions aren't supported" with a path.

F-CK9: BYPASS counts automaton pushes as the player's work

  • from: Clockwork
  • side: creator
  • severity: medium
  • owner (suggested): engine
  • status: open

tools/solve.mjs flags BYPASS only when no word moves at all. On a level where a mouse pushes the WIN word, a player who just walks and waits is never flagged. Yet "the player never touched a word" is exactly the skip an author wants to know about. mouse-keeps-time is hands-off on purpose, and nothing in the tool output says so. My prototype verifier (/tmp/clockwork-proto/verify.mjs) adds a HANDS-OFF flag: won without the player's own push chain moving a word (moves tagged cause: 'automaton' are ignored). Speed is the same problem as F-CW4. mouse-hole and escapement return LIMIT at the default --max 300000 and need 3 to 4 million expansions, about 3 minutes each. Proposed fix: add HANDS-OFF to tools/solve.mjs for levels with automata, and let a level declare allowHandsOff: true. For speed, see F-CW4.

F-TW1: A sunk word's rule stays active for the next move (ghost rule)

  • from: Tidewater
  • side: creator and player
  • severity: high
  • owner (suggested): engine
  • status: open

step() parses rules, transforms, and then resolves contacts, but it never re-parses rules after contacts remove things. If a sentence is still whole on the turn one of its words sinks (for example, pushing ROCK IS STOP right until STOP enters water), state.rules still contains ROCK IS STOP. The next move into a rock is rejected with reason stop. A rejected move doesn't refresh rules either, so the ghost lasts until some other move is accepted. The ledger shows a rule whose word is gone. Repro: /tmp/tw/repro.mjs, also in docs/worlds/tidewater.md, "Surprising engine behaviour" 1. No Tidewater level depends on this. My search port had to copy the stale behaviour to match the engine. Proposed fix: call refreshRules() (and transform()) again after resolveContacts() when anything was removed. Add a regression test with the repro. Decide whether the post-removal transform also applies in the same turn. I suggest yes, to reach a fixed point.

F-TW2: "Become a boat to cross the water" can't be expressed

  • from: Tidewater
  • side: creator
  • severity: medium
  • owner (suggested): design (decide), engine
  • status: open

The world brief asked for "becoming a CRAB or BOAT to cross what MIAU cannot". In the core engine, SINK removes every occupant of the cell whatever its noun, so a BOAT sinks exactly like a cat. DEFEAT is the only hazard that depends on identity (it only removes YOU things), and nothing grants immunity. Identity puzzles therefore reduce to "become whoever is already on the other side" (sideways), or to changing the hazard itself (shell-game, last-boat-out). Proposed fix: add FLOAT as the next core property: a FLOAT thing and a SINK thing in one cell don't interact, as in the reference game. It's the smallest addition that makes vehicles, bridges, and "moored" objects authorable. Until then, document in docs/modding.md that SINK ignores identity.

F-TW3: Anything placed on water vanishes at reset, silently

  • from: Tidewater
  • side: creator
  • severity: medium
  • owner (suggested): engine
  • status: open

reset() resolves contacts, so a boat moored on a WATER IS SINK cell, or a jelly floating in the sea, is destroyed before the first frame. The level loads as if the author forgot the object. Nothing reports it: not loadMod, not the solver. This also blocks design's crumbling-tile default ("place WATER under it"). See "Crumbling tiles (proposal)" in docs/worlds/tidewater.md. Proposed fix: have validation (or the solver) warn "N things are destroyed at reset (BOAT at column 4, row 2 sinks)". Longer term, FLOAT (F-TW2) makes these stacks legal.

F-TW4: A palette entry for MIAU silently turns off the cat's facing

  • from: Tidewater
  • side: creator and player
  • severity: medium
  • owner (suggested): engine
  • status: fixed in the working tree (src/render/facing.js now inherits built-in facing when the glyph matches); kept for the record

src/render/board.js only applies the built-in DIRECTIONAL_NOUNS facing when a pack has no palette entry for the noun. Every example pack (word-lab, garden-basics) writes MIAU: '🐈' as a plain string, so in those packs the cat never turns around, and nothing warns about it. I only found this by reading the renderer. Proposed fix: when a pack's plain-string glyph is identical to the built-in glyph for that noun, inherit the built-in faces. Update the example packs to { glyph: '🐈', faces: 'left' }, and say so in docs/modding.md.

F-TW5: A syntax error in a pack gives no line number

  • from: Tidewater
  • side: creator
  • severity: low
  • owner (suggested): engine
  • status: open

An apostrophe in a single-quoted hint ('You can't reach...') produced Could not load tidewater.cjs: Unexpected identifier 't'. There was no line or column, because the source runs through new Function, and the sourceURL comment doesn't surface in the message. With a pack made of text maps, finding it meant bisecting by hand. An apostrophe in a hint is also the most likely mistake a writer will make. Proposed fix: before new Function, parse the source with a cheap syntax check that reports line and column, or catch the SyntaxError and read the line from error.stack, adjusting for the 2 prepended lines. In the Workshop error note, show the offending line.

F-TW6: Newer emoji (🪼, 🪸) have no fallback glyph

  • from: Tidewater
  • side: player
  • severity: low
  • owner (suggested): design
  • status: open

🪼 (Unicode 15, 2022) and 🪸 (Unicode 14) render well on current Apple fonts, but show tofu on older Android and Windows 10. The jelly is the hazard in 5 of 7 levels, and an unreadable hazard is a fairness problem. The palette takes exactly one glyph per noun, so a creator has no way to say "or 🎐 if that's missing". Proposed fix: allow palette[NOUN] = { glyph: '🪼', fallback: '🎐' }, with the renderer checking glyph support once (canvas measureText width against the tofu width). Or document a "safe emoji" floor (Unicode 13) for bundled packs.

F-TW7: Walking into water is instant and untelegraphed

  • from: Tidewater
  • side: player
  • severity: medium
  • owner (suggested): design
  • status: open

SINK resolves in the same step as the move. The first time a player learns that water is deadly is when the cat disappears, and in one-for-the-tide the sacrifice of a crab reads exactly like a mistake. The world depends on the player choosing what to feed the sea, so the difference between "fed the sea" and "fell in" matters. Undo recovers it, but the lesson arrives as a punishment. Proposed fix: (a) a one-frame "about to sink" wobble on a YOU thing whose next step in the held direction enters a SINK or DEFEAT cell. It's presentation-only and computed from properties(). (b) A distinct sink effect when a YOU is lost while other YOUs remain ("one for the tide"), not the same effect as a crate or word sinking.

F-TW8: Transformation beats contact, and the player can't see why they survived

  • from: Tidewater
  • side: player
  • severity: low
  • owner (suggested): design
  • status: open

In one step you can complete JELLY IS SHELL and walk onto a jelly. The jelly becomes a shell before DEFEAT is checked, so you survive (repro in docs/worlds/tidewater.md, behaviour 2). It's a good moment, but the transform tween and the move tween play together, so it looks like the cat walked into a jellyfish and nothing happened. Proposed fix: play the transform pop before the move settles (a 40 to 60 ms lead) whenever a moved YOU ends on an entity that transformed this step, so the order of events is visible.

F-TW9: A crumbling tile can't have water "under" it in the current contact model

  • from: Tidewater (crumbling tiles proposal)
  • side: creator
  • severity: medium
  • owner (suggested): design (decide), engine
  • status: open

Design's default for crumbling tiles says "a level can place WATER under it to make the hole deadly". A cell with DRIFT and WATER under WATER IS SINK is destroyed at reset (F-TW3), so that authoring pattern can't exist without a floor layer. Full semantics and two draft levels are in docs/worlds/tidewater.md, "Crumbling tiles (proposal)". Proposed fix: a crumble becomes a leftover noun (leaves: 'WATER', keeping the entity id) instead of revealing what's underneath. Express counts above 1 as a leftover chain (SAND, then SHOAL, then WATER) rather than a counter, so the state stays in noun and undo, sharing, and the solver hash need no change.

F-TW10: Solver capacity decided the size of the finale (+1 to F-CW4 and F-XW8)

  • from: Tidewater
  • side: creator
  • severity: medium
  • owner (suggested): engine
  • status: open

The first 12×9 draft of last-boat-out ran tools/solve.mjs --max 2000000 out of memory (a 4 GB heap crash, not LIMIT). A compact port reached 13 million states without a solution. The shipped 10×7 version needs 261k states and about 90 s, with a peak RSS of 1.7 GB. A mutable-state port with compact string keys (/tmp/tw/eng.mjs, /tmp/tw/fs.mjs, differentially tested with 0 mismatches over 57k random steps) is about 20× faster and supports "solve without rule X" and "solve without sinking a word". Those constraint modes are how I proved each level's key edit is necessary. Proposed fix: adopt a compact-key, mutable-state solver and add --without-rule "<RULE>" and --no-word-loss constraint flags. They answer "is my intended idea required?" (F-CW5, F-XW10) directly.

F-DS1: shell-game's key rule can't be machine-proven

  • from: design (CI setup)
  • side: creator
  • severity: low
  • owner (suggested): content
  • 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)

tidewater/shell-game --forbid 'JELLY IS SHELL' hits LIMIT at 3M states. Per the engine audit, the authored words over-approximate alternative rules, and a lexical shortcut would be unsound. Proposed fix: rework the proof with fewer loose words and a smaller open area, so the forbid search terminates. Then add mustForm: ['JELLY IS SHELL'].

F-XW14: Proving mustForm cost Crossword its decoy words

  • from: crossword (W6 rework)
  • side: creator
  • severity: medium
  • owner (suggested): engine
  • status: open

--forbid has to prove UNSOLVED exhaustively. Each loose word multiplies the state space, and words can drift through non-STOP things (a column of DEFEAT rocks) into the whole board. In shove-the-danger, the three loose words (AND, PUSH, and a decoy STOP) hit LIMIT at the 2M default and needed about 5M to prove. pass-it-on needed 9.5M at 10 by 7. To make the proofs fit, I removed the decoy and narrowed both boards (7 by 7 and 8 by 7). The decoy was good design: it made the player choose PUSH over STOP. spend-it-twice proves at 1,905,020 states, 95% of the default, so it now declares solverBudget: 4000000 for margin. A full pack run takes about 6 minutes. This is a sibling of F-XW8, F-TW10, and F-DS1. Proposed fix: make the forbid search prune by word-motion symmetry, or by a static dead-word analysis (a word in an edge row or column that can't reach any target cell). Also consider a per-level CI budget report that shows how close each proof is to its cap.

F-XW15: mustForm can't express "some crossing rule"

  • from: crossword (across-and-down)
  • side: creator
  • severity: low
  • owner (suggested): engine
  • status: open

across-and-down teaches "one IS, read across and down". --forbid 'FLAG IS WIN' solves in 26 steps through a different crossing (FLAG IS YOU down, then MIAU IS WIN across), so the level's real invariant is "some vertical sentence shares the IS". mustForm only takes exact rule texts, so I declared nothing, and the level can't be regression-checked. Proposed fix: allow structured requirements such as mustForm: [{ axis: 'v' }], { predicate: 'WIN' }, or { sharesWordWith: 'MIAU IS YOU' }, checked against rule.axis and rule.sourceIds, with the same forbid-based proof.

F-XW16: A precedence change silently broke shipped levels

  • from: crossword (W6 rework)
  • side: creator and player
  • severity: high
  • owner (suggested): engine
  • status: open

W6 (D23) changed STOP versus PUSH, self-DEFEAT, X IS X, transform order, and stacked parsing. Packs still declare engine: 1, and nothing in load or solve told me my levels changed. I found out only when the design lead mentioned that carry-the-pair now hit LIMIT (its old route killed the cat) and that shove-the-danger's decoy had changed meaning. A player who had installed the old pack would find a proof unwinnable with no explanation. Proposed fix: bump the manifest engine (or add a rules semantic version) whenever precedence changes. Warn on load when a pack targets older semantics. Run every bundled and world pack through tools/solve.mjs with its mustForm in CI, so a semantics change fails loudly.

F-TW11: A per-id string leaves can't express a crumble chain

  • from: Tidewater (FRAIL fixtures)
  • side: creator
  • severity: medium
  • owner (suggested): engine (schema), design (confirm)
  • status: resolved (D25 revised: leftovers are a per-level noun map in mechanics[frail].leaves, looked up by current noun; fixtures updated)

D25 says leaves stays with the entity id and that multi-step crumbling is a noun chain. Those two decisions conflict. SAND with leaves: 'SHOAL' crumbles into a SHOAL that still carries leaves: 'SHOAL', so the second crossing turns SHOAL into SHOAL, and the sandbar never runs out. Draft B ("Two tides") can't be authored with a string. docs/worlds/fixtures/frail.json (two-tides) uses a proposed map form instead. Resolution (accepted, D25 revised): leftovers are a per-level noun map only: mechanics: [{ id: 'frail', type: 'frail', version: 1, state: 'derived', leaves: { SAND: 'SHOAL', SHOAL: 'WATER' } }]. There's no per-object leaves field and no string form. A crumbling thing becomes leaves[currentNoun] and keeps its id, and a noun missing from the map is removed. Per-object variety (one plank leaves WATER, another nothing) is given up in exchange for rules players can read ("sand turns to shoal"); a level that needs two behaviours uses two nouns.

F-TW12: A loose FRAIL sentence lets NOUN IS WIN skip the crossing

  • from: Tidewater (FRAIL fixtures)
  • side: creator
  • severity: low
  • owner (suggested): content
  • status: open

The first "One plank each" fixture left DRIFT IS FRAIL loose, following the draft's "break the rule to keep the bridge" alternative. The solver immediately swapped FRAIL for the loose WIN and won by touching a plank (DRIFT IS WIN, 14 steps). Any FRAIL level with a loose property word and walkable frail things has this shortcut. The fixture now pins the sentence in a corner. Proposed fix: a guideline for FRAIL levels: pin the FRAIL sentence, or make sure no loose property word can take its slot. Add a solver lint that warns when the winning rule's subject is a noun whose only role was FRAIL.