clone

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

main
miau-is-true / docs / worlds / crate-works.md
crate-works.md 20.7K source

Crate Works (world bible)

Pack: mods/worlds/crate-works.cjs, id crate-works, 7 levels.

World identity (declared in manifest.world; loadMod accepts the field today without validating it, see F-CW8):

world: { glyph: '📦', tagline: 'Every crate goes somewhere. So does every word.', accent: 'push' }

Theme

A loading dock after hours. The cat works the night shift: crates have to be shoved out of doorways, gaps in the dock floor have to be filled before anything can cross, and the only way out is the truck. The rule tiles are treated as cargo too. They block corridors, they sink into gaps, and sometimes the word you need is sitting inside a rule you're still using.

Noun Glyph Role
MIAU 🐈 (faces: 'left', profile) The player
CRATE 📦 Cargo. Usually PUSH, sometimes YOU, sometimes nothing
TRUCK 🚚 The goal (WIN). In one level it is also PUSH
GAP 🚧 A hole in the dock floor (SINK)
WALL slab (the renderer ignores the palette glyph for WALL) Racking. Always STOP, always sealed away from the player

The glyphs are plain strings except the cat. 🚚 is drawn in profile too, but it only ever moves by being pushed, and pushing doesn't change facing, so it doesn't declare faces.

Mechanic

Sokoban pushing, played through the core vocabulary only (YOU PUSH STOP WIN SINK, AND, and noun-to-noun transforms). Three ideas carry the world:

  1. Gaps are Sokoban goals that eat the box. Any pushable thing that enters a GAP IS SINK cell disappears along with the gap and leaves floor behind. That makes "push every box into a hole" levels possible, which is the closest the core engine gets to real Sokoban.
  2. Words are cargo. Words are always PUSH, so they fill gaps too. Spending a word removes its rule, so when you spend it matters.
  3. The goal can move. TRUCK IS PUSH AND WIN means you can never stand on the truck. The only way to share a cell with it is to push it onto a second YOU that is held still by a wall. That's the core-engine version of "box on goal".

Levels

Solver: node tools/solve.mjs mods/worlds/crate-works.cjs --max 2000000 (all 7 SOLVED, no BYPASS, no TRIVIAL). Cross-checked with a compact re-implementation of the core rules (differentially tested against src/engine.js) that also verifies the key rule is necessary: forbidding CRATE IS YOU makes in-step and back-it-up unsolvable, and forbidding GAP IS CRATE makes night-shift unsolvable.

# id Title Category Board Teaches Solver steps Flags Intended insight
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 _.
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 _.
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.
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.
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 _.
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.
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.

Solutions (from tools/solve.mjs, shortest):

crates-move    RRRRRRRURRDDRDR
fill-the-gap   DRRRRRRDLDDRURRRR
spend-it-last  LLLLURULLLLDRRRRRRRRULLLLLLLLLLLLL
in-step        ULLUUUDDRUUDDDDRRRRUULDDLUURRUU
load-order     UURRRDULLDRLDDRULURRLDDRULURRRRD
back-it-up     RRURDDDRUULLDLLLLULL
night-shift    UUUURRRLLLDDDDRRRRUUUUUDLLLLUURRRRRRLLDDRRDDDRRR

Arc: 1 to 3 teach push, sink, and "words are cargo", in that order. 4 adds the second YOU. 5 is the pure push-order twist. 6 and 7 combine an earlier idea with an edit players don't expect: move the goal instead of the player, and turn the hazard into cargo.

Design notes

  • Sealing the rules costs rows. Nothing in the core can make a word immovable, and walls only block if WALL IS STOP stays intact. Every level keeps its fixed rules in a sealed "office": a word row with a full wall row under it, or the top row packed with words and walls, since the board edge stops upward pushes. Two early drafts were broken this way. load-order v1 solved in 15 moves by pushing STOP up out of WALL IS STOP from the room below. spend-it-last v1 used gaps as row-0 filler and was solved by sinking a word into one and then pushing GAP IS SINK sideways into the next (F-CW2).
  • Pinning tricks used. A word in row 0 can't be pushed up, and row 0 is unreachable if every cell in it is a word or a wall. A word in row 1 with walls to its left and right, under a row-0 word, is also pinned. That's how GAP / IS / _ in night-shift and CRATE / IS / PUSH in spend-it-last give the player exactly one editable slot.
  • Material counting. Every pushable thing is gap filler, words included. A moat has to be thicker than the number of loose words plus crates, or it can be crossed by spending words. night-shift relies on this: one loose word and a two-deep moat.
  • Accidental "X IS WIN". The first draft of crates-move was won by pushing WIN onto CRATE IS PUSH, which made CRATE IS WIN. Any loose WIN next to a sentence ending in a property is a shortcut risk.
  • Lockstep reads as a puzzle. In in-step the solver found a line where the cat uses the crate's blocked moves to reposition. That's the intended insight, and it isn't obvious from the board. The hint points at it ("A wall holds one still while the other moves").
  • Passable crates look identical to pushable ones. After night-shift turns the gaps into crates, those crates have no properties and can be walked through, but nothing on the board shows it (F-CW13).

Goal feature spec: a real Sokoban goal

Problem. Real Sokoban is won when every box is on a goal square. The core can only express "a YOU shares a cell with a WIN thing". Crate Works gets around this three ways: holes that eat boxes, moving the WIN thing onto a parked YOU, and making the crate the YOU. None of them expresses "all boxes home". Multi-box levels can't have a win state that depends on where the boxes are.

Proposal: a new core property GOAL.

  • Syntax: an ordinary predicate, so DOCK IS GOAL, GAP AND DOCK IS GOAL, and DOCK IS GOAL AND STOP all parse with the existing rule code. The tile uses the win property colour family and is outlined, not filled, so it reads as "a win condition" but not as WIN.
  • Win check (end of turn, in resolveContacts, after SINK/DEFEAT removal and alongside the existing WIN check): the level is won if
    1. at least one YOU exists, and
    2. at least one GOAL thing exists, and
    3. every GOAL thing shares its cell with at least one other entity that is currently PUSH (object or word).
  • WIN keeps working. Either condition wins.
  • Why "covered by a PUSH thing" and not "covered by a CRATE": it has no extra parameters, and it composes with rules. Breaking CRATE IS PUSH stops crates from counting. Words count, because words are always PUSH, so a word parked on a dock is a legal cover. BOX IS PUSH lets any noun become cargo.
  • Edge cases: the check never passes vacuously (condition 2). A GOAL thing that is also PUSH needs a second PUSH entity on it. SINK removes covers before the check runs. Transforms apply before the check.
  • Engine cost: no new state, because it's derived from positions and rules. Undo, reset, sharing, and the solver hash need no changes. settled() should report goals: { covered, total } so the HUD or ledger can show DOCK IS GOAL 2/3.
  • Rejected alternative: a level-data predicate such as goal: { every: 'CRATE', on: 'DOCK' }. It's invisible on the board, so players can't read it or edit it. It needs a new metadata schema and a separate UI. It also can't take part in rule puzzles, which is the point of this game.

Both drafts below were checked with a prototype of exactly this rule (a GOAL patch on the compact simulator in /tmp/cw/sim-goal.mjs). Drafts only, not in the pack. Legend additions: + DOCK, * CRATE on DOCK, word D DOCK, word O GOAL.

Draft G1: Pallet row (pure Sokoban, BYPASS on purpose)

// DRAFT, needs the proposed GOAL property. Solves in 27: LUURRDULLDDRRRRUULLLRDDLLUU
board([
  'MIY.WIS.CIP.',
  '############',
  '#+..#.######',
  '#.$....#####',
  '#.#$#..#DIO#',   // DOCK IS GOAL, sealed in the racking
  '#.@...+#####',
  '############',
], { id: 'pallet-row', title: 'Pallet row', category: 'optional',
     hint: 'Every dock wants a crate. Corners are forever.', allowBypass: true });

The first real Sokoban proof. No word needs to move, so the solver reports BYPASS(27). That's the point of the level, which is why it declares allowBypass.

Draft G2: Short one crate (a word is cargo)

// DRAFT, needs the proposed GOAL property. Solves in 36 (prototype):
// RRRRRURDLLLUULDULLDLDRRRRRRDDRRUURUL
board([
  'MIY.WIS.CIP.',
  '############',
  '#.....#....#',
  '#.$.$.#.+..#',
  '#.@...N.+..#',   // three docks, two crates, one loose WIN word in the doorway
  '#.....#.+..#',
  '#######....#',
  '############',
  '...DIO......',
], { id: 'short-one-crate', title: 'Short one crate', category: 'challenge',
     hint: 'Three docks, two crates. Something else on this floor can be pushed.' });

There are two crates for three docks. The third cover is the loose WIN word, which does nothing as a rule here (there's no X IS in front of it) but is PUSH like every word. There's a push-order problem too: WIN starts in the doorway, so it goes through first and has to end up on the far dock without blocking the others.

Gates and switches (proposal)

Provisional. The user may define other semantics. Three draft scenarios below were run on a prototype (/tmp/cw/sim-gate.mjs, a gate patch on the compact simulator) under the recommended default and under two alternatives (ONLY_PUSH, CRUSH). Results are quoted after each scenario.

Decisions

Question Choice Why (scenario)
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.
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.
When do gates change? End of turn, after movement, transforms, and contacts. Gates never change mid-turn. With two YOUs (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.
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.
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.
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.
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.

Draft D1: Door stop (occupancy, anything presses)

// DRAFT. '_' = PLATE (channel amber), '|' = GATE (channel amber).
// Prototype: 15, LURRURDDURRRRRR (same under ONLY_PUSH and CRUSH).
board([
  'MIY.WIS.CIP.',
  '............',
  'GIK.........',
  '############',
  '#.....#TIN##',
  '#.$.$.|.~.&#',   // gate, then a gap, then the truck
  '#.@...######',
  '#..._.######',   // plate two cells off the gate row
  '############',
], { id: 'door-stop' });

One crate has to sit on the plate and the other has to fill the gap behind the gate. The gate is open for as long as the first crate stays on the plate. BYPASS in the solver sense (no word moves). As shipped content it would need a rule edit, for example a missing PUSH.

Draft D2: Hand-off (end-of-turn timing, anything presses)

// DRAFT. Prototype: 15, LUDLURRRRRURRRR. UNSOLVED under ONLY_PUSH.
board([
  'WIS.TIN....',
  '###########',
  '#.......###',
  '#..MIY..|.&',    // the cat's corridor runs through the gate
  '#CA.....###',    // push A then C up: CRATE AND MIAU IS YOU
  '#..@....###',
  '###########',
  '#$....._###',    // the crate's lane ends on the plate, wall behind it
  '###########',
], { id: 'hand-off' });

After CRATE AND MIAU IS YOU, the crate walks right onto the plate (turn 12) while the cat walks its room. The crate then stays on the plate because the wall at the end of its lane absorbs the move, and the cat crosses. The crate isn't PUSH, so only "anything presses" makes this work. The shortest route never bumps the closed gate. The fixture's same-turn-block probe (LLUDRURRRRURRRRR) covers that case. On turn 13 the crate lands on the plate while the cat, next to the gate, is refused. The gate opens at the end of the turn, and the cat crosses on turn 14.

Draft D3: Jam (a gate never closes on an occupant)

// DRAFT. Prototype: 7, RRRRRRR. UNSOLVED under CRUSH (the cat is crushed).
board([
  'MIY.WIS.TIN',
  '###########',
  '#......#..#',
  '#.@.$._|.&#',    // plate right next to its own gate
  '#......#..#',
  '###########',
], { id: 'jam' });

There is no CRATE IS PUSH, so the crate is inert and the cat walks through it. (An earlier version of this paragraph said the crate is pushed, which was wrong. The route is the same.) The cat presses the plate itself (turn 4), and the gate opens at the end of that turn. It steps into the gate (turn 5): the plate is now empty but the cell is occupied, so the gate is open and jammed rather than closing on the cat. When the cat steps out (turn 6), the gate closes behind it. This also shows the authoring hazard: a plate next to its own gate lets a lone YOU skip the circuit.

Undo, reset, sharing, and solver requirements

  • Snapshot: no stored gate bit (superseded, and reconciled 2026-09-24 with the accepted add-channel-linked-gates and add-declarative-mechanic-state-contract, design D25). An earlier draft of this section stored open: boolean per gate, on the theory that a jam depends on history. It doesn't: a closed gate blocks entry, so anything standing in a gate cell means the gate is being held open. Gate state is therefore derived from the current board at end of turn: pressed(channel) = any occupant on any plate of that channel; open = pressed(channel) or the gate cell is occupied; jammed = occupied and not pressed. That covers the start-of-level and transform-back cases too (D25: "state is recomputed, and the jam rule applies if occupied"). Plates hold no state either. Undo and reset restore positions and nouns, and gate state follows.
  • Authoring. Object records need an optional channel field, and the object() helper needs a 5th argument or an options bag. Today object() only takes (noun, x, y, id), so the drafts spread the record by hand. The loader should validate channel against the fixed palette and warn when a channel has plates but no gates, or the reverse.
  • Sharing. channel must survive exportPack, packLink, and install. It's plain data, with no code.
  • Solver. Because gate state is a pure function of positions and nouns, the state key needs nothing extra. Two states that look identical are identical. (The prototype's per-gate key character is superseded. It was only needed while gates carried a hidden bit.) The D1 to D3 drafts remain useful as regression fixtures for the engine's derived semantics.
  • Regression fixtures. D1 to D3 are exported as data at fixtures/gates.json, with routes and observations re-derived under the derived semantics. None of the three shortest routes changed from the stored-bit prototype: D1 LURRURDDURRRRRR (15), D2 LUDLURRRRRURRRR (15), D3 RRRRRRR (7). Format of each entry:
    • level: { id, width, height, mechanics, objects }. Every object has an explicit, readable, stable id: d1-gate-a, d1-plate-a, or d1-<noun>-<x>-<y> and d1-word-<text>-<x>-<y> for everything else, where x and y are the starting cell. PLATE and GATE objects carry channel: 'amber'.
    • mechanics: the minimal declaration [{ id: 'gates', type: 'channel-gates', version: 1, channels: ['amber'] }]. type names the host mechanic, and channels lists the channel palette entries the level uses, so a loader can reject a channel with no declaration. The mechanic is derived, so it declares no state.
    • expectedRoute: the shortest winning route under the derived semantics. routeChangedFromStoredBitPrototype is false for all three.
    • observations: the gate and plate projections after turn 0, after every turn where any open, jammed, or pressed value changes, and after the winning turn. Entries are { afterTurn, move, gates: [{ id, open, jammed }], plates: [{ id, pressed }], won }. An entry also gets refusedByClosedGate: [ids] when a YOU was refused by a closed gate on a turn that was otherwise accepted.
    • probes (D2 only): extra non-shortest routes that exercise a rule. Each probe has the same observation format.
    • The engine currently rejects channel and mechanics, so these fixtures are for the upcoming implementation. They were generated with /tmp/cw/gen-gates.mjs on the derived prototype /tmp/cw/sim-gate.mjs.
  • Events. settled() should report gates: [{ id, from, to, cause: 'pressed' | 'released' | 'jammed' }] so juice and audio can react.