clone

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

main
miau-is-true / docs / worlds / tidewater.md
tidewater.md 19.6K source

Tidewater (world bible)

Pack: mods/worlds/tidewater.cjs, id tidewater, 7 levels.

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

world: { glyph: '🌊', tagline: 'The sea keeps whatever you give it.', accent: 'sink' }

Theme

A coastline at changing tide. The water swallows whatever walks or is pushed into it: rocks, words, and cats. Jellies sting whatever is YOU. A crab lives on the island you can't reach. Out past the waves, the last boat is waiting.

The emotional line of the world is giving things to the sea. First you turn the tide off. Then you feed it a sentence you don't need. Then a sentence that was hurting you. Then you stop being the cat and become the crab. Then you turn the stingers into prizes. Then you let one crab go so the others can cross. Finally you turn the jellies into more of yourself and walk the whole family into the waves so that one cat reaches the boat.

Noun Glyph Role
MIAU 🐈 (faces: 'left', profile) The player
WATER 🌊 The sea (SINK). One cell of water swallows one thing, and is used up doing it
JELLY 🪼 The hazard (DEFEAT), and later the raw material for transformation
CRAB 🦀 (plain, drawn face-on) The other identity. It lives where the cat can't go
SHELL 🐚 The goal in most levels (WIN)
BOAT ⛵ (plain, no clear profile) The goal of the finale (WIN)
ROCK 🪨 A barrier (STOP) whose own sentence becomes bridge material
REEF 🪸 Declared for future levels; unused in 1.0.0

I checked the glyphs on Apple Color Emoji against #0F0E0D at 58px. All of them read. 🪼 is dark blue and has the lowest contrast of the set, but it's still legible. 🪼 and 🪸 are newer emoji (Unicode 15 and 14), so older Android and Windows 10 devices will show tofu (F-TW6). Only the cat declares faces. 🦀 is drawn face-on, and ⛵ has no reliable heading across fonts.

Mechanic

Core vocabulary only. The world is about hazards and identity, and three engine facts carry it:

  1. SINK takes everything in the cell, including the water. A pushed rock, a pushed word, or a walking YOU all vanish together with one water cell. Every fill costs something. Words are the most common fill, so the world is really about which sentence can you spare.
  2. DEFEAT only removes YOU things. Words sit on jellies safely, and a transformed jelly stops stinging.
  3. Identity is a sentence. CRAB IS YOU moves control across the water. JELLY IS SHELL turns hazards into goals. JELLY IS MIAU turns hazards into more of you, and several YOUs in a line fill water one per turn.

What the engine can't express is the brief's original "become a BOAT to cross what MIAU cannot". There's no FLOAT, and SINK doesn't care what you are. The world uses "become whoever is already on the other side" instead (F-TW2).

Levels

Solver: node tools/solve.mjs mods/worlds/tidewater.cjs --max 2000000. All 7 levels are SOLVED, with no BYPASS and no TRIVIAL. The full pass takes about 90s, most of it on last-boat-out, with a peak RSS of 1.7 GB. I also used a compact port of the turn rules. It's differentially tested against src/engine.js (57,579 random steps compared, 0 mismatches), and every path it finds is replayed on the real engine. I used it to check that the intended edit is necessary:

  • paper-bridge and sink-the-sting are UNSOLVED if no word may sink.
  • sideways is UNSOLVED without CRAB IS YOU.
  • one-for-the-tide is UNSOLVED if no crab or cat may be lost.
  • last-boat-out is UNSOLVED without JELLY IS MIAU (147,349 states exhausted).
  • shell-game declares mustForm: ['JELLY IS SHELL'], and the committed CLI proves it: node tools/solve.mjs mods/worlds/tidewater.cjs --level shell-game --forbid 'JELLY IS SHELL' --max 3000000 returns UNSOLVED after 623,612 states (F-DS1).
# id Title Category Board Teaches Solver steps Flags Intended insight
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.
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.
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.
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.
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.
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.
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.

Why the challenge is non-obvious

  • The rule edit reuses a word that already works in another direction. MIAU is the subject of the locked horizontal MIAU IS YOU and becomes the predicate of the vertical JELLY IS MIAU. JELLY never moves; only the IS does.
  • The count is tight. After the edit, the only sinkable word is DEFEAT (JELLY and the moved IS are pinned to column 0, and MIAU IS YOU is locked). With three waves, two cats have to go into the sea.
  • Alignment is the second half. The three cats are in three rows and move in lockstep. The top edge of the board is the tool that lines them up in one row, in the order they'll fall.

Design notes

  • Pinning rules. A sentence is fixed in two ways: it's flush in a corner (MIAU IS YOU in the bottom-left of last-boat-out), it runs down the board edge flush into a corner (both sentences in shell-game), or it's flanked by jellies the cat can't stand on (JELLY IS in shell-game), or it's across the water (every WATER IS SINK). Across-the-water pinning reads naturally in this world ("the rule for the sea is on the far shore"), so it costs no extra rows.
  • Word economy is the difficulty dial. In every water level, count the words that are spare (not needed for YOU or WIN, and not pinned) and compare that with the width of the water. sideways has two waves and one spare word, so the level forces the identity swap.
  • Map helper. The pack draws levels as text maps (map(id, rows, meta)) with a legend of one-letter things and uppercase words. Ids come from position (<level>-<x>-<y>), with the known caveat in F-CW14.
  • Blank-row spacing. This is honoured everywhere except the vertical far-shore sentences in last-boat-out, which sit in their own column.
  • Hints name an observation, never a sequence: "The sea is not fussy about what you feed it."; "There is no shell on this beach. Yet."; "Three waves and one cat. Try reading MIAU from above."

Surprising engine behaviour (with minimal reproductions)

All of these were checked on src/engine.js. The scripts are in /tmp/tw/repro.mjs and /tmp/tw/repro2.mjs (scratch).

  1. Ghost rule after a word sinks (F-TW1). Rules are parsed before contacts resolve, and they aren't re-parsed after removals. If a sentence is still whole on the turn one of its words sinks, the rule stays in state.rules, and it keeps working for the next move. A blocked move doesn't refresh rules either, so the ghost lasts until some move is accepted.

    // 6x3. Words: MIAU IS YOU (row 0, x 0-2), WATER IS SINK (row 0, x 3-5),
    // ROCK IS STOP (row 1, x 1-3). Things: MIAU (0,1), WATER (4,1), ROCK (1,2).
    g.step(1, 0);  // pushes ROCK IS STOP right; STOP enters the water and sinks
    // state.rules still contains 'ROCK IS STOP'
    g.step(0, 1);  // MIAU steps down into the rock: rejected, reason 'stop'
    
  2. Transformation resolves before contact. In one step you can push the last word of JELLY IS SHELL into place and walk onto a jelly. The jelly is a shell before DEFEAT is checked, so you survive (and win if SHELL IS WIN), even though JELLY IS DEFEAT is still on the board.

  3. Transformation chains advance one link per accepted move. With ROCK IS SHELL and SHELL IS JELLY, the rock is a SHELL at reset and becomes a JELLY after the next step (also reported in F-XW6).

  4. SINK and DEFEAT both beat WIN in the same cell. SHELL IS WIN AND SINK: walking onto the shell removes both and doesn't win. JELLY IS WIN while JELLY IS DEFEAT is active: touching the jelly kills you first, which is a nice trap in last-boat-out.

  5. Nothing can start on water. Reset resolves contacts, so a boat or jelly placed on a WATER IS SINK cell is gone before the first frame, with no warning (F-TW3). "Moored" and "floating" things can't be authored.

  6. Lockstep YOUs fill water in order. The front mover is planned first. A line of YOUs walking into water loses one per wave, and the rest walk on over the filled cells. controlLost only fires when the last YOU is gone.

  7. A mod palette entry for a built-in noun used to turn off its facing. MIAU: '🐈' as a plain string never flipped (F-TW4). The working-tree src/render/facing.js now inherits the built-in facing when the glyph matches. Tidewater declares { glyph: '🐈', faces: 'left' } anyway.

Crumbling tiles (proposal)

The engine team asked for concrete desired interactions for tiles that crumble after one step. Tidewater is the natural home for them: driftwood that sinks after you cross, and sandbars the tide takes back. Everything below is provisional. The drafts are not in the .cjs, and the user may define different semantics.

The design default, and one challenge to it

Design's default is: a rule-driven property (DRIFT IS FRAIL); the tile crumbles when its occupant leaves; any mover counts; it leaves an empty cell; a level can place WATER under it to make the hole deadly; the counter is 1 only.

I agree with all of it except "place WATER under it". It can't work in the current contact model. A cell holding DRIFT and WATER while WATER IS SINK is active is a two-occupant cell with a SINK thing, so reset destroys both before the first frame (behaviour 5 above). Making it work would need a floor layer, meaning FRAIL things shield their cell from contact. That adds precedence rules every other property would have to respect.

Counter-proposal: a crumble becomes its leftover. Each frail object may carry leaves: '<NOUN>' in level data. When it crumbles, it is transformed into that noun, keeping its id. With no leaves, it's removed. This reuses the transform event and tween. The deadly hole is simply leaves: 'WATER', and it's only as deadly as WATER IS SINK currently is, so players can still reason about it with sentences.

Semantics, decided by the scenarios

Question Decision Why (scenario)
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.
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.
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.
What's left behind? Nothing by default. leaves makes it a noun (usually WATER). See above. Both drafts use leaves: 'WATER'.
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.
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.

Undo, reset, sharing, and the solver

  • With the leftover-chain design, all crumble state is the noun, which the snapshot, undo, reset, sharing, and tools/solve.mjs (keyOf hashes id, x, y, and noun) already cover.
  • If the engine ever adds a numeric counter, it has to live on the object record in state.objects so that structuredClone history carries it. reset() must rebuild it from level data (today it drops unknown fields; see F-CW17), and the solver key must include it. Otherwise BFS merges distinct states and reports false UNSOLVED or wrong shortest paths.
  • Shared packs need leaves validated by loadMod (it must be a noun token) and preserved by exportPack.

Draft A: "One plank each" (DRAFT, not in the pack)

d is DRIFT with leaves: 'WATER'. DRIFT IS FRAIL is new vocabulary.

// 11x7. The shell is across three planks. WIN is on the near shore and must
// cross too.
[
  'MIAU IS YOU . . w w w . . .',
  '. . . . . w w w . . .',
  '. m . WIN . d d d . s .',
  '. . . . . w w w . . .',
  'DRIFT IS FRAIL . . w w w SHELL IS .',
  '. . . . . w w w . . .',
  '. . . . . w w w WATER IS SINK',
]

Intended solution: push WIN right across the planks. Each plank that WIN leaves is entered by the cat in the same step, which is a handover, so the planks hold. Then the cat steps off plank 3, it becomes water, and the bridge is gone behind you. Carry WIN into SHELL IS _ from above and touch the shell. Alternative: break DRIFT IS FRAIL first, and the planks stay. This tests step-off, end-of-turn evaluation, any mover counting, and the rule form.

Draft B: "Two tides" (DRAFT, not in the pack)

a is SAND with leaves: 'SHOAL', and h is SHOAL with leaves: 'WATER'. The rule is SAND AND SHOAL IS FRAIL.

// 12x7. The IS you need is on the island. You go out and come back, so each
// sandbar is crossed twice.
[
  'MIAU IS YOU . . w w w . . . .',
  '. . . . . w w w . . . .',
  '. m . . . a a a . IS . .',
  '. . r . . w w w . . . .',
  'SAND AND SHOAL IS FRAIL w w w . . .',
  '. . . . . w w w . . . .',
  'SHELL . WIN . s w w w WATER IS SINK .',
]

Out: sand becomes shoal. Back, pushing IS ahead: shoal becomes water behind you. You can't go out a third time. The trap: if you push the rock r across first "to test the bridge", it spends one crossing (any mover counts), and you're stranded on the island. The glyph change from sand to shoal is the visible counter.

Open questions for the engine

  1. Does a frail tile crumble when it's transformed away (for example DRIFT IS ROCK)? I suggest no: only departures count.
  2. Does a word standing on a plank count as an occupant for handover? I suggest yes, for consistency with "any mover".
  3. FRAIL on a YOU thing (MIAU IS FRAIL): I suggest it has no effect, because FRAIL only triggers on things that are left, not on the mover.

Regression fixtures (D25 semantics)

D25 accepted FRAIL and, as revised, makes leftovers a property of the noun. Three data-only fixtures for the upcoming engine implementation are in docs/worlds/fixtures/frail.json. Each entry has id, title, level (explicit stable object ids and a mechanics declaration), expectedRoute, steps, observations, exercises, notes, and proposedFields. The engine currently rejects mechanics, so the fixtures are data only and aren't in the pack.

id Route Leftover map Exercises
one-plank-each (Draft A) 14 RRRRRRRRURDDUL { DRIFT: 'WATER' } Step-off crumbles on turns 5, 6, and 7. Handovers on turns 4, 5, and 6: the pushed word WIN leaves a plank while the cat enters it. DRIFT becomes WATER with the same id.
two-tides (Draft B, no rock) 30 RRRRRRRURRDLLLLLLLLDLUUUDDDDRR { SAND: 'SHOAL', SHOAL: 'WATER' } Two-link chain on the same id: SAND becomes SHOAL on turns 5 to 7, then SHOAL becomes WATER on turns 15 to 17. Handovers while pushing IS back on turns 14 to 16. SAND AND SHOAL IS FRAIL.
log-walks-off (new, minimal) 8 RRRRRRRR {} A FRAIL mover never crumbles itself: LOG is FRAIL and YOU, and it moves every turn. A different FRAIL mover triggers a crumble: LOG leaving the SAND it shared at reset. An unmapped noun is removed (to: null).

Declaration (D25 revised). Each level declares mechanics: [{ id: 'frail', type: 'frail', version: 1, state: 'derived', leaves: { <NOUN>: <NOUN> } }]. On a crumble, the thing becomes leaves[currentNoun] and keeps its id. A noun missing from the map is removed. There's no per-object leaves field. Because the lookup uses the current noun, a chain like SAND, then SHOAL, then WATER needs no counter. This resolves F-TW11. Moving from my per-object map to the per-level map changed no route and no observation: I re-ran all three fixtures and they matched exactly.

How the routes were derived. I used a scratch simulator (/tmp/tw/frail.mjs). It uses the engine's own parser (src/engine/rules.js) plus a port of the current movement, transform, and contact code. The crumble checkpoint runs after movement and transforms and before contacts. A frail thing crumbles only if it:

  • was FRAIL at the start of the turn and is FRAIL at the checkpoint,
  • didn't move,
  • had a distinct starting occupant that moved away,
  • and is alone at the checkpoint.

With crumbling disabled, the simulator matches src/engine.js on 33,645 random steps over the three fixture boards. For that comparison, FRAIL is registered as a property through a wait-only automaton. Each route is the first shortest route BFS finds, trying U, D, L, R in that order. Other routes of the same length may exist, so compare route length and replay the observations.