clone

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

main
miau-is-true / openspec / changes / add-frail-leftover-tiles / design.md
design.md 7.9K source

Context

The current turn lifecycle moves player-controlled things, reparses and transforms rules, advances automata, reparses and transforms again, then resolves contacts. A frail tile is a normal object with an authored stable ID; it is not an underlying floor layer and does not shield a cell from contacts. The mechanic therefore uses the existing object noun and the foundation's mechanic lifecycle rather than adding a second board representation.

Goals and non-goals

Goals

  • Make FRAIL a normal rule-driven property.
  • Make departure, handover, and leftover timing deterministic and visible.
  • Count things and words consistently with existing movement and SINK rules.
  • Keep the level-scoped noun map attached to the FRAIL mechanic definition; keep the transformed noun and stable entity ID in authoritative object state through transforms, contacts, Undo/reset, shared payloads, and solver replay.
  • Support chains without introducing a numeric counter or renderer-only state.

Non-goals

  • A floor layer beneath an object.
  • A durability counter, crack decoration, or automatic multi-step timer.
  • A new contact precedence rule for SINK, DEFEAT, or WIN.
  • JavaScript callbacks in a CommonJS module.

Decisions

Frail is a parsed property with a level-scoped declaration

The host recognizes FRAIL as a supported property. A level that uses the mechanic declares one level-scoped entry:

mechanics: [{
  id: 'frail',
  type: 'frail',
  version: 1,
  state: 'derived',
  leaves: { SAND: 'SHOAL', SHOAL: 'WATER', DRIFT: 'WATER' },
}]

The declaration has no runtime counter or per-object leaves field. A level may author DRIFT IS FRAIL, SAND AND SHOAL IS FRAIL, or another valid noun/property sentence. Active FRAIL membership is derived from the settled rules and current noun, just like other rule-driven properties. The property word can therefore be pushed or removed to change future behavior.

Departure is sampled at a turn boundary

For an accepted turn, the host captures the set of objects occupying each cell before movement. It then resolves player movement, reparses/transforms, and resolves active automata using the ordinary atomic push rules. After the final transform phase and before contacts, it evaluates every object that is FRAIL at that checkpoint:

  1. The object was a FRAIL object in the starting settled cell and is still a FRAIL object at the crumble checkpoint.
  2. A distinct occupant moved away from that cell during this turn. A pushed word, pushed thing, player, or automaton qualifies, even if that distinct mover is also FRAIL.
  3. No other entity occupies the frail cell at the crumble checkpoint.

If all three conditions hold, the frail entity crumbles. A mover entering the cell in the same accepted turn is a handover and prevents crumbling. This avoids evaluating the same plank once per mover and makes a pushed word leaving one plank while YOU enters it safe.

The moving FRAIL entity itself never triggers crumbling for its old cell. If a FRAIL entity is pushed or walks away, it remains the entity at its new cell; its own movement is not a departure trigger. Transforming a frail object away does not count as departure; the level-scoped map is consulted only if its current noun later crumbles while FRAIL.

Leftovers are nouns, not counters

The frail.leaves map is validated as a map of syntactically valid noun tokens. A leftover need not be present among the initially authored objects; the solver codec includes every map key and value in its level-bound token table. The map is static level data and is not copied onto objects. When an object crumbles, from is its current noun and:

  • if frail.leaves[from] exists, it changes into that noun at the same coordinate and keeps its stable ID;
  • if the current noun is absent from the map, it is removed from the object set.

The resulting noun is part of the same settled turn. If it is also FRAIL and a later turn has a distinct occupant depart its cell, it can crumble again. This expresses multi-crossing designs as an authored chain, for example SAND -> SHOAL -> WATER, using the current noun as the lookup key and without hidden counts.

Contacts follow the crumble checkpoint

Crumbled leftovers or removed objects participate in the existing contact phase. A frail.leaves[from] = 'WATER' result is therefore subject to the current WATER IS SINK rule on that same turn, with the existing contact precedence. Nothing can be authored as a frail object sitting on a water hazard to bypass initial contacts: reset still resolves initial contacts before the first frame, and there is no floor layer.

State and event contract

The mechanic's static definition holds the authored noun map; the dynamic noun and existence remain in state.objects. The settled result adds crumbled, ordered deterministically by stable entity ID, with { id, from, to }, where to is the mapped leftover noun or null for removal. The event is cloned with the snapshot and is presentation-only. Undo and reset restore the prior object noun/existence exactly; sharing and CommonJS export preserve the validated mechanic declaration; the solver key includes object ID, noun, position, existence, and any registered mechanic projection inputs, without a separate FRAIL counter or per-object leaves value.

Automata and solver

Automata are ordinary movers for departure and handover purposes. Their action is resolved before FRAIL evaluation, so a word or thing moved by an automaton can spend a frail cell and a handoff remains safe. The compact solver uses the same transition order and replays crumbled results through authoritative restore. A same-board state with a different current noun or object existence is distinct.

Tidewater proof scenarios

The Tidewater draft fixtures remain world-team owned; these scenarios define the engine acceptance cases without editing the pack:

  • Draft A, One plank each: DRIFT IS FRAIL, three DRIFT entities, and a frail.leaves entry of DRIFT: WATER, with a word pushed across them. Each word departure is safe while YOU hands over the cell; after YOU leaves the final plank it becomes water. The accepted fixture pins the FRAIL sentence.
  • Draft B, Two tides: frail.leaves entries SAND: SHOAL and SHOAL: WATER prove a chain as YOU crosses out, then pushes IS back over the bridge for the second crossing. The accepted fixture has no ROCK.
  • Word and automaton crossing: a pushed word and an active automaton each leave distinct FRAIL cells; both produce stable crumble events.
  • Initial SINK: a frail.leaves WATER result and a frail object authored on a WATER IS SINK cell use existing reset/contact behavior, with no shielding exception.

Risks and mitigations

  • Half-turn evaluation crumbles a handover. Evaluate once after all movement and transforms, then require the destination cell to be empty.
  • A leftover chain disappears from the solver. Keep current noun and existence in the authoritative object state and include them in the compact key; keep the noun map level-bound and add encode/decode/restore parity fixtures without introducing a hidden crumble counter.
  • Unknown metadata changes sharing. Validate the FRAIL declaration and every map key/value as a noun token and reject malformed values atomically. Reject per-object leaves as unsupported metadata.
  • Contact ordering becomes ambiguous. Keep crumble before the existing contact phase and test SINK, DEFEAT, and WIN with a same-turn leftover.

Open questions

  • When several entities crumble in one accepted turn, is stable ID ordering sufficient for the renderer's animation and event list, or should authored cell/reading order be the presentation order? The simulation result is independent of that ordering, but the final presentation contract needs a design choice before the renderer is implemented.
  • The exact Tidewater fixture IDs and visual glyphs for DRIFT, SAND, and SHOAL remain with the worlds/design teams.