clone

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

main
miau-is-true / docs / worlds / clockwork-api.md
clockwork-api.md 23.8K source

Clockwork: custom property contract

Owner: Clockwork world-builder. Audience: the engine team, then design. Grounded in src/engine.js and src/mods.js as of commit 78137b1 plus the uncommitted automata work on 09-23.

Clockwork proves a mod can add custom rules. It uses two properties:

Property Kind Status
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.
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.

Both are data, not code. An earlier JS-handler draft of this API was dropped on the engine team's direction. Its findings are recorded in Rejected: JS handlers, because they explain why data is the right call.

Ask for the engine team

Most of the first round landed: subject is optional (the rule word carries the pattern), wait() is exported, the solver restores phase and tries W, and a thing the player pushed still takes its beat. The remaining asks, in priority order:

  1. Stop the silent noun trap. A word the parser doesn't know is a noun. If a level says DOOR IS SHUT without declaring SHUT, every door silently turns into an unglyphed SHUT thing and stops being STOP. That's what happens on the shipped engine today to the draft level key-and-door, which becomes free to walk through. Ask: at load time, warn about any predicate word that isn't a declared property and isn't a noun that appears in the pack's objects, palette, or vocabulary. Also reject an object whose noun equals a declared property word. validateLevel accepts { noun: 'TICK' } today.
  2. Draw declared properties as property tiles. src/render/board.js hard-codes TICK into its PROPERTIES set. A pack that names its property WIND or SPIN gets a noun-style tile. Derive the set from level.automata[].property (and level.pairs[].words later).
  3. Let the player learn the word. Accept an optional pack-level glossary, properties: { TICK: { accent: 'push', describe: 'Repeats its steps, one for each of your turns.' } }. accent is a property colour token. describe is one sentence of at most 80 characters, shown the first time the tile appears and in the ledger. The shipped loader already passes this field through untouched. Clockwork declares it.
  4. Keep the loader and engine in step. The engine accepts 'U'/'W' shorthand steps, but mods.js rejects them. Accept both in both places, or neither.
  5. Reconcile the OpenSpec change. openspec/changes/add-rule-driven-automata/specs/.../spec.md still describes a different model (one automaton, step IDs, property additions and removals) from the code (many automata, move and wait steps). Clockwork follows the code.
  6. Make the solver usable on automata levels. At the default --max 300000, mouse-hole and escapement return LIMIT. They need 3 to 4 million expansions and about 3 minutes each, mostly in the BYPASS pass. A compact state key (only moved or changed objects, plus phase) and a per-level solverMax hint would help.
  7. Ship level.pairs (the OPEN/SHUT contract below), or tell us it's out of scope so Clockwork can retire it.
  8. Feature flags instead of silent ignore. automata shipped without a schema bump. An older build ignores the field, so the level loads with its property word as a noun. Proposal: manifest.features: ['automata'] (later 'pairs'). A loader that doesn't know a listed feature rejects the pack with the player line "This pack needs a newer Miau." manifest.engine stays 1.

Complete proof example. This is Clockwork level 1 exactly as the pack builds it. The shipped tools/solve.mjs solves it in 11 steps (DRRRRRRRRRR).

const L = { action: 'move', dx: -1, dy: 0 };
const R = { action: 'move', dx: 1, dy: 0 };
const w = (text, x, y) => ({ kind: 'word', text, x, y });
const o = (noun, x, y) => ({ kind: 'object', noun, x, y });
const row = (noun, y, xs) => xs.map(x => o(noun, x, y));
const all = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

module.exports = {
  manifest: { id: 'clockwork-proof', name: 'Clockwork proof', version: '1.0.0', engine: 1 },
  palette: { MIAU: { glyph: '🐈', faces: 'left' }, MOUSE: { glyph: '🐁', faces: 'left' }, WALL: '🧱', BELL: '🔔' },
  levels: [{
    id: 'mouse-keeps-time',
    title: 'The mouse keeps time',
    hint: 'The mouse takes one step for every step you take.',
    width: 11,
    height: 7,
    automata: [{ id: 'mouse-shuttle', subject: 'MOUSE', property: 'TICK', phase: 0, pattern: [L, L, L, L, L, R, R, R, R, R] }],
    objects: [
      w('MIAU', 0, 0), w('IS', 1, 0), w('YOU', 2, 0),
      w('WALL', 4, 0), w('IS', 5, 0), w('STOP', 6, 0),
      w('MOUSE', 8, 0), w('IS', 9, 0), w('TICK', 10, 0),
      ...row('WALL', 2, all),
      o('WALL', 0, 3), w('BELL', 1, 3), w('IS', 2, 3), w('WIN', 5, 3), o('MOUSE', 9, 3), o('WALL', 10, 3),
      ...row('WALL', 4, all),
      o('MIAU', 0, 5),
      o('BELL', 10, 6),
    ],
  }],
};

Expected result: the mouse walks left and pushes WIN into BELL IS _ on beat 5. On its next left run it waits against the full sentence. The cat reaches the bell in 11 steps. If TICK weren't declared, MOUSE IS TICK would turn the mouse into a TICK thing at reset.

TICK: the automata contract Clockwork relies on

This section documents the shipped behaviour Clockwork depends on. A change to any line here would break a level.

Declaration (per level, data only):

automata: [{
  id: 'mouse-shuttle',        // stable, unique in the level; phase is keyed by it
  subject: 'MOUSE',           // optional; omit it and every noun that IS TICK follows the pattern
  property: 'TICK',           // custom property word, never a core word
  phase: 0,                   // optional starting index
  pattern: [                  // 1 to 32 steps
    { action: 'move', dx: -1, dy: 0 },
    { action: 'wait' },
  ],
}]

Semantics:

  • Parsing. Every declared property joins the level's property set, so MOUSE IS TICK is a property rule and never a transformation.
  • Activation. A record is active when some rule X IS <property> holds, where X is its subject if it has one. Its actors are all things that have the property and match the subject.
  • Beat. Each accepted player turn is one beat, whether it's a move or a wait(). A blocked move isn't a turn, so no beat happens.
  • Phase. Each record has its own phase. It advances by one only on a beat where the record is active and has at least one actor, so a paused clock resumes where it stopped. Records with different subjects but the same word keep separate phases.
  • Action. A move uses the player's push planner: it pushes PUSH things and word tiles, STOP blocks it, and it walks onto anything else (including YOU). A blocked actor waits in place and the phase still advances. Actors move front-first along the step direction, then by id.
  • Turn order. Player move, then parse and transform, then all automata (records by id), then parse and transform, then one contact pass (SINK, then DEFEAT, then WIN).
  • Undo, replay, and solver. Phase is in state.automata, so snapshots, restore(), and the solver's state key include it.

Consequences for level design (all found while building Clockwork):

  • Contacts run once, at the end of the turn. A YOU that is also TICK moves twice per turn and never stops on the cell in between, so it can step over a DEFEAT line (double-time). A transform caused by an automaton's push lands before contacts (escapement: the cat steps onto a cog on the very turn the cogs become bells).
  • A moving DEFEAT or STOP can't guard a corridor. The player can swap cells with it head-on, or let it walk onto them. A timing puzzle built on a patrolling hazard always has a timing bypass. Clockwork uses static hazards and moving helpers instead.
  • Rules move with the word. With no subject, pushing TICK from MOUSE IS TICK to BELL IS TICK hands over the pattern and its phase (ring-for-service).

OPEN and SHUT: proposed level.pairs

This is the next extension in the same declarative style. It adds no code hooks, no new state, and no new turn phase.

pairs: [{ words: ['OPEN', 'SHUT'], effect: 'destroy-both', enter: true }]
Field Rule
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.
effect Only 'destroy-both' in this slice.
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.
Limit At most 4 pairs per level.

Resolution. At the start of every contact pass, before SINK, each cell is checked for each pair in declaration order. Things with the first word and things with the second word are matched one for one, in entity id order. A thing never pairs with itself. Every matched thing is destroyed, and the settled event's destroyed entries carry cause: 'open' (the first word, lowercased). Unmatched extras survive.

Ordering. Pairs resolve after automata, because they're part of the single contact pass. An OPEN mouse that walks into a SHUT door on its beat opens it that same turn (let-the-mouse-in).

Undo, replay, and solver. Pairs are stateless. Destruction is part of the object list, so snapshots, restore(), and the solver work unchanged. The prototype check replays key-and-door twice with identical states and events, and Undo of the opening turn restores both the key and the door.

Validation and sharing. Pairs are plain data, so a shared link carries them the same way it carries automata. mods.js validates the fields above with player-readable messages.

Presentation. Design needs a destruction cue for cause: 'open'. It's different from sink and defeat: a click, then both sprites drop away. The glossary (ask 3) explains both tiles.

Security and determinism

  • Automata and pairs are data. They run inside the host's own turn code, so they add no execution surface, and a shared link stays data-only.
  • Everything is ordered by position projection and entity id. There's no randomness, no wall clock, and no reliance on object key order.
  • Limits: 32 automata of 32 steps, 4 pairs, and one beat per turn, so there's no cascade to cap.

Rejected: JS handlers

The first draft let a pack register properties: { MOVE: { phase, run(view, self) } } with pure handlers that returned queued commands (move, turn, destroy) and an admits(view, mover, blocker) predicate. It worked in a prototype (kept at /tmp/clockwork-proto/attic/engine-handlers.js, about 270 changed lines), including rollback of a throwing handler. It was dropped for four reasons:

  1. loadMod clones the export with structuredClone, which throws run(){} could not be cloned. on any function.
  2. The pack library, URL links, and exports store data only, so a pack with handlers can't be persisted or shared without sending code.
  3. Handlers needed engine-owned facing state (dir) that doesn't exist today. reset() silently drops unknown object fields such as dir.
  4. Error isolation needs whole-turn rollback and a fault channel through every caller. Data can't throw.

Appendix A: prototype

Location: /tmp/clockwork-proto/.

  • base/: snapshot of src/engine.js and src/mods.js (09-23, with the automata slice).
  • src/: the snapshot plus the pairs patch. Apply it with python3 patch-engine.py && python3 patch-mods.py, which re-applies onto a fresh base/.
  • tools/solve.mjs: a copy of the repo solver that imports ../src.
  • verify.mjs: the same BFS with an extra HANDS-OFF flag (won without the player pushing a word), usable against the shipped engine (--engine real) or the prototype (--engine proto).
  • clockwork-pairs.cjs: the three draft OPEN/SHUT levels.
  • checks.mjs: the replay, Undo, and noun-trap evidence quoted above.
  • replay.mjs: prints the board after a move string.

Engine diff: +55 and -2 lines. Loader diff: +40 lines.

Engine diff (relative to src/engine.js)

--- a/src/engine.js
+++ b/src/engine.js
@@ -6,6 +6,9 @@
 const key = (x, y) => `${x},${y}`;
 const directions = [[1, 0], [0, 1]];
 const TOKEN_RE = /^[A-Z][A-Z0-9_-]*$/;
+/** Clockwork proposal: declarative paired contact effects. */
+export const PAIR_EFFECTS = Object.freeze(['destroy-both']);
+export const MAX_LEVEL_PAIRS = 4;
 
 function validateLevel(level) {
   if (!level || typeof level !== 'object') throw new TypeError('level must be an object');
@@ -50,6 +53,22 @@
       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`);
     });
   }
+  if (level.pairs !== undefined) {
+    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}`);
+    const pairWords = new Set();
+    level.pairs.forEach((pair, i) => {
+      if (!pair || typeof pair !== 'object' || !Array.isArray(pair.words) || pair.words.length !== 2) throw new TypeError(`${context}: pairs[${i}] needs two property words`);
+      for (const word of pair.words) {
+        const upper = typeof word === 'string' ? word.toUpperCase() : '';
+        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`);
+        if ((level.automata || []).some(system => String(system.property).toUpperCase() === upper)) throw new TypeError(`${context}: pairs[${i}] word ${upper} is already an automaton property`);
+        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`);
+        pairWords.add(upper);
+      }
+      if (!PAIR_EFFECTS.includes(pair.effect)) throw new TypeError(`${context}: pairs[${i}] effect must be ${PAIR_EFFECTS.join(' or ')}`);
+      if (pair.enter !== undefined && typeof pair.enter !== 'boolean') throw new TypeError(`${context}: pairs[${i}] enter must be a boolean`);
+    });
+  }
   return clone(level);
 }
 
@@ -71,7 +90,8 @@
       return { ...action };
     }),
   }));
-  const dynamicProperties = new Set(automataDefs.map(system => system.property));
+  const pairDefs = (original.pairs || []).map(pair => ({ words: pair.words.map(word => word.toUpperCase()), effect: pair.effect, enter: pair.enter === true }));
+  const dynamicProperties = new Set([...automataDefs.map(system => system.property), ...pairDefs.flatMap(pair => pair.words)]);
   const semanticProperties = new Set([...CORE_PROPERTIES, ...dynamicProperties]);
   let state;
   let history = [];
@@ -179,11 +199,43 @@
     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);
     for (const o of state.objects) if (o.kind === 'object' && changes.has(o.noun)) o.noun = changes.get(o.noun);
   }
+  // Pairs resolve before SINK, DEFEAT, and WIN: in each cell, things with the
+  // first word and things with the second word destroy each other one for one,
+  // matched in entity id order. A thing never pairs with itself.
+  function resolvePairs() {
+    const causes = new Map();
+    if (!pairDefs.length) return causes;
+    const byId = (a, b) => String(a.id).localeCompare(String(b.id));
+    for (const pair of pairDefs) {
+      const cells = new Map();
+      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); }
+      const removed = new Set();
+      for (const occupants of cells.values()) {
+        if (occupants.length < 2) continue;
+        const firsts = occupants.filter(o => properties(o).has(pair.words[0])).sort(byId);
+        const seconds = occupants.filter(o => properties(o).has(pair.words[1])).sort(byId);
+        for (const a of firsts) {
+          if (removed.has(a.id)) continue;
+          const b = seconds.find(candidate => candidate.id !== a.id && !removed.has(candidate.id));
+          if (!b) continue;
+          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 }); }
+        }
+      }
+      if (removed.size) state.objects = state.objects.filter(o => !removed.has(o.id));
+    }
+    return causes;
+  }
+  // A mover may enter a cell held by its pair partner even if that partner is STOP or PUSH.
+  function enters(mover, blocker) {
+    if (!pairDefs.length) return false;
+    const a = properties(mover), b = properties(blocker);
+    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]))));
+  }
   function resolveContacts(evaluateWin = true) {
+    const causes = resolvePairs();
     const cells = new Map();
     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); }
     const removed = new Set();
-    const causes = new Map();
     for (const occupants of cells.values()) {
       // A single entity can carry both YOU and WIN after a rule changes.  It
       // wins without needing a second occupant in its cell.
@@ -213,6 +265,7 @@
     const collisions = state.objects.filter(other => other.id !== o.id && other.x === tx && other.y === ty && !planned.has(other.id));
     for (const other of collisions) {
       const traits = properties(other);
+      if (enters(o, other)) continue;
       if (traits.has('STOP')) return { ok: false, blocked: { id: o.id, dir: [dx, dy], blockerId: other.id, reason: 'stop' } };
       if (traits.has('PUSH')) {
         const result = planAutomatonMove(other, dx, dy, planned, plannedPushers, o.id, order);

Loader diff (relative to src/mods.js)

--- a/src/mods.js
+++ b/src/mods.js
@@ -11,6 +11,8 @@
 export const MAX_MOD_LEVELS = 64;
 export const MAX_LEVEL_OBJECTS = 4096;
 export const MAX_LEVEL_AUTOMATA = 32;
+export const MAX_LEVEL_PAIRS = 4;
+const PROPERTY_ACCENTS = new Set(['you', 'push', 'stop', 'win', 'sink', 'defeat']);
 export const MAX_AUTOMATON_PATTERN = 32;
 
 const ID_RE = /^[a-z][a-z0-9._-]{1,63}$/;
@@ -160,6 +162,43 @@
         }
       });
     }
+  }
+}
+
+// Clockwork proposal: level.pairs and the pack-level properties glossary.
+function validatePairs(level, path, errors) {
+  if (level.pairs === undefined) return;
+  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; }
+  const automatonWords = new Set((Array.isArray(level.automata) ? level.automata : []).map(a => String(a?.property).toUpperCase()));
+  const nouns = new Set((Array.isArray(level.objects) ? level.objects : []).filter(o => o?.kind !== 'word' && o?.text === undefined).map(o => String(o?.noun).toUpperCase()));
+  const seen = new Set();
+  level.pairs.forEach((pair, i) => {
+    const pairPath = `${path}.pairs[${i}]`;
+    if (!isRecord(pair)) { errors.push(issue(pairPath, 'must be an object')); return; }
+    for (const key of Object.keys(pair)) if (!['words', 'effect', 'enter'].includes(key)) errors.push(issue(`${pairPath}.${key}`, 'unsupported field'));
+    if (!Array.isArray(pair.words) || pair.words.length !== 2) errors.push(issue(`${pairPath}.words`, 'must name exactly two property words'));
+    else pair.words.forEach((word, j) => {
+      const upper = typeof word === 'string' ? word.toUpperCase() : word;
+      if (!token(upper) || RESERVED.has(upper)) errors.push(issue(`${pairPath}.words[${j}]`, 'must be a new uppercase property word'));
+      else if (seen.has(upper) || automatonWords.has(upper)) errors.push(issue(`${pairPath}.words[${j}]`, `${upper} is already declared in this level`));
+      else if (nouns.has(upper)) errors.push(issue(`${pairPath}.words[${j}]`, `${upper} is also used as a noun in this level`));
+      seen.add(upper);
+    });
+    if (pair.effect !== 'destroy-both') errors.push(issue(`${pairPath}.effect`, "must be 'destroy-both'"));
+    if (pair.enter !== undefined && typeof pair.enter !== 'boolean') errors.push(issue(`${pairPath}.enter`, 'must be true or false'));
+  });
+}
+
+function validateGlossary(mod, errors) {
+  if (mod.properties === undefined) return;
+  if (!isRecord(mod.properties)) { errors.push(issue('properties', 'must map property words to { accent, describe }')); return; }
+  for (const [word, entry] of Object.entries(mod.properties)) {
+    const path = `properties.${word}`;
+    if (!token(word) || RESERVED.has(word)) errors.push(issue(path, 'key must be a new uppercase property word'));
+    if (!isRecord(entry)) { errors.push(issue(path, 'must be { accent, describe }')); continue; }
+    for (const key of Object.keys(entry)) if (!['accent', 'describe'].includes(key)) errors.push(issue(`${path}.${key}`, 'unsupported field'));
+    if (!PROPERTY_ACCENTS.has(entry.accent)) errors.push(issue(`${path}.accent`, `must be one of ${[...PROPERTY_ACCENTS].join(', ')}`));
+    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'));
   }
 }
 
@@ -182,6 +221,7 @@
     const levelIds = new Set();
     mod.levels.forEach((level, index) => {
       validateLevel(level, index, errors);
+      if (isRecord(level)) validatePairs(level, `levels[${index}]`, errors);
       if (typeof level?.id === 'string' && LEVEL_ID_RE.test(level.id)) {
         if (levelIds.has(level.id)) errors.push(issue(`levels[${index}].id`, `duplicates another level id (${level.id})`));
         else levelIds.add(level.id);
@@ -189,6 +229,7 @@
     });
   }
   validatePalette(mod.palette, errors);
+  validateGlossary(mod, errors);
   if (mod.vocabulary !== undefined) {
     if (!Array.isArray(mod.vocabulary)) errors.push(issue('vocabulary', 'must be an array of uppercase token strings'));
     else mod.vocabulary.forEach((word, index) => {
@@ -208,6 +249,7 @@
       ...(typeof automaton.subject === 'string' ? { subject: automaton.subject.toUpperCase() } : {}),
       ...(typeof automaton.property === 'string' ? { property: automaton.property.toUpperCase() } : {}),
     })) } : {}),
+    ...(Array.isArray(level.pairs) ? { pairs: level.pairs.map(pair => ({ ...pair, words: pair.words.map(word => word.toUpperCase()) })) } : {}),
     objects: level.objects.map(object => ({
       ...object,
       kind: object.kind || (object.text !== undefined ? 'word' : 'object'),

Appendix B: draft pair levels (prototype solver)

id title steps flags idea
key-and-door Every door has a key 17 none Build KEY IS PUSH AND OPEN, then push the key into the locked door.
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.
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.

Command: node /tmp/clockwork-proto/verify.mjs /tmp/clockwork-proto/clockwork-pairs.cjs --engine proto --max 4000000. On the shipped engine the same file loads without an error, and every level is broken. SHUT and OPEN become nouns, and doors turn into SHUT things.