Clockwork: custom property contract
Owner: Clockwork world-builder. Audience: the engine team, then design.
Grounded in src/ and src/ 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:
- Stop the silent noun trap. A word the parser doesn't know is a noun. If a
level says
DOOR IS SHUTwithout declaringSHUT, every door silently turns into an unglyphedSHUTthing and stops beingSTOP. That's what happens on the shipped engine today to the draft levelkey-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.validateLevelaccepts{ noun: 'TICK' }today. - Draw declared properties as property tiles.
src/hard-codesrender/ board.js TICKinto itsPROPERTIESset. A pack that names its propertyWINDorSPINgets a noun-style tile. Derive the set fromlevel.automata[].property(andlevel.pairs[].wordslater). - 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.' } }.accentis a property colour token.describeis 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. - Keep the loader and engine in step. The engine accepts
'U'/'W'shorthand steps, butmods.jsrejects them. Accept both in both places, or neither. - Reconcile the OpenSpec change.
openspec/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.changes/ add-rule-driven-automata/ specs/ .../ spec.md - Make the solver usable on automata levels. At the default
--max 300000,mouse-holeandescapementreturnLIMIT. 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-levelsolverMaxhint would help. - Ship
level.pairs(theOPEN/SHUTcontract below), or tell us it's out of scope so Clockwork can retire it. - Feature flags instead of silent ignore.
automatashipped 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.enginestays1.
Complete proof example. This is Clockwork level 1 exactly as the pack
builds it. The shipped tools/ 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
propertyjoins the level's property set, soMOUSE IS TICKis a property rule and never a transformation. - Activation. A record is active when some rule
X IS <property>holds, whereXis itssubjectif 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
PUSHthings and word tiles,STOPblocks it, and it walks onto anything else (includingYOU). 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, thenDEFEAT, thenWIN). - 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
YOUthat is alsoTICKmoves twice per turn and never stops on the cell in between, so it can step over aDEFEATline (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
DEFEATorSTOPcan'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, pushingTICKfromMOUSE IS TICKtoBELL IS TICKhands 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 /, about 270 changed
lines), including rollback of a throwing handler. It was dropped for four
reasons:
loadModclones the export withstructuredClone, which throwsrun(){} could not be cloned.on any function.- The pack library, URL links, and exports store data only, so a pack with handlers can't be persisted or shared without sending code.
- Handlers needed engine-owned facing state (
dir) that doesn't exist today.reset()silently drops unknown object fields such asdir. - Error isolation needs whole-turn rollback and a fault channel through every caller. Data can't throw.
Appendix A: prototype
Location: /.
base/: snapshot ofsrc/andengine.js src/(09-23, with the automata slice).mods.js src/: the snapshot plus thepairspatch. Apply it withpython3 patch-engine.py && python3 patch-mods.py, which re-applies onto a freshbase/.tools/: a copy of the repo solver that importssolve.mjs ../.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 draftOPEN/SHUTlevels.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 /.
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.