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-declarative-mechanic-state-contract / design.md
design.md 8.5K source

Context

createGame() owns authoritative settled state, history, restore, and turn resolution. A turn currently moves player-controlled objects, reparses and transforms rules, advances automata, reparses and transforms again, then resolves contacts and victory. state.objects and automata phases are carried through snapshots, while the solver's codec can accept dynamic fields only through an explicit scalar registry. The missing seam is a mechanic lifecycle that all of those boundaries can consume.

Goals and non-goals

Goals

  • Keep mechanic definitions data-only, deterministic, and validated before play.
  • Give every mechanic one authoritative state envelope and one turn lifecycle.
  • Preserve that state through reset, accepted and rejected turns, Undo, restore, solver replay, pack export, and data-only share links.
  • Make event and solver registration requirements visible before a mechanic is eligible for bundled-world acceptance.
  • Keep mechanics host-defined. A CommonJS pack declares supported metadata but does not provide callbacks or executable behavior.

Non-goals

  • Decide the semantics of gates, switches, plates, FRAIL, crumbling, counters, or floor layers.
  • Add a renderer, audio cue, or visual language for a mechanic.
  • Permit remote or untrusted code to implement mechanics.
  • Replace the existing object, automaton, rule, or contact model.

Decisions

Use a normalized declarative definition

After mod normalization, a level may contain a mechanics array. Each entry has a stable lowercase id, a host-supported type, a version, static configuration, and state: 'derived' or state: 'stateful'. The host adapter, not the mod, owns a stateful scalar schema and initial values. Static configuration may identify authored object IDs or channels, but it cannot contain functions, URLs, or values outside the host schema. The host assigns a unique order by mechanic ID and validates all object references before creating a game.

The exact fields for a concrete mechanic are defined by its later capability spec. This foundation only requires that the host can validate the definition, initialize state when needed, and supply either a state codec registration or a derived no-hidden-state assertion.

Separate derived projections from stateful mechanics

The settled snapshot may contain mechanics, keyed by mechanic ID, for stateful adapters. Each stateful value is owned by the host implementation and contains only declared fields. The engine initializes it from the normalized level, clones it with the same transaction snapshot as objects and automata, and rejects unknown IDs or fields during restore.

A derived adapter declares an empty state contract and recomputes its projection from settled objects, rules, occupancy, and authored metadata. Derived outputs such as gate openness, plate pressure, jam status, and FRAIL existence are not stored as a second mutable truth and are not redundantly encoded in solver frontier bytes. A mechanic must not hide mutable state in renderer, juice, module globals, or authored object fields.

For levels without mechanics, the existing public behavior remains unchanged; the envelope may be omitted from serialized data when empty. Stateful mechanics must have a complete initialized value; derived mechanics must have an explicit empty state contract.

Define explicit phase hooks, not arbitrary callbacks

The engine exposes a host-internal lifecycle with deterministic phases:

  1. beforeMovement: inspect the settled state and prepare blockers or intent.
  2. afterMovement: observe player movement before the first rule refresh.
  3. afterTransforms: observe the final noun/rule projection after automata movement and the second rule refresh and transform.
  4. beforeContacts: apply mechanic effects that intentionally precede SINK or DEFEAT contacts.
  5. afterContacts: observe destruction and final occupancy.
  6. settled: finalize state and event data.

The phase order is part of the engine contract. The existing player refresh and transform, automata advance, and final refresh and transform remain between afterMovement and afterTransforms; no hook sees a half-resolved state in that interval. A concrete mechanic must state which hooks it uses. Hook implementations are registered host code selected by a validated type; mod data cannot add a callback.

All stateful hook mutation is transactional. Rejected or complexity-blocked turns do not commit mechanic state. A mechanic must be deterministic from the authored definition, the prior settled state, and the current phase inputs.

Treat events as the presentation boundary

Settled results retain the existing movement, rule, transformation, destruction, and automata fields and gain a normalized mechanics collection only when a mechanic reports a transition. Each record contains the mechanic ID, stable subject ID when applicable, from and to state values or a declared event payload, and a bounded cause/type token. The engine returns cloned event data so presentation cannot mutate authoritative state.

The foundation does not prescribe names such as pressed or crumbled. Concrete mechanic specs define their event vocabulary and ordering.

Make solver registration mandatory

The solver codec receives the same compiled host-owned mechanic plan as the engine. Each stateful registration has a stable mechanic ID, an ordered schema, and a codec version. The registry must encode every dynamic field that can affect a future turn. A derived registration instead identifies its authoritative projection inputs and asserts that no additional mutable bit is required. Unknown mechanic IDs, fields, types, missing values, or unregistered runtime state fail with actionable errors. Static authored metadata stays outside the frontier blob.

The plan supplies explicit codec noun tokens used by a mechanic's static configuration. A leftover noun may not occur in the initial board or authored vocabulary, so the FRAIL adapter must add both map keys and values before codec token indices are assigned. Arbitrary mechanic strings are not assumed to be nouns.

The registry may use fixed authored slots and scalar fields already supported by the codec. If a future mechanic needs arrays, maps, or more than the scalar range, it must add an explicit deterministic codec representation rather than falling back to JSON key order. Encoding and decoding must round-trip through game.restore() without applying a transform or advancing an automaton.

Preserve data through pack and share boundaries

Mod validation, normalization, catalog registration, export, and data-only sharing preserve the normalized mechanic definitions. Shared links never carry executable mechanic code. A pack declaring a mechanic type unsupported by the host fails with a feature diagnostic before installation; it is not treated as an ordinary noun transformation.

Risks and mitigations

  • State omission merges solver futures. Fail closed on unknown dynamic fields and require a same-board/different-state identity fixture per later mechanic.
  • Hook order diverges from gameplay. Add public/fast parity tests at every lifecycle boundary before enabling solver acceptance.
  • Metadata drifts during sharing. Round-trip normalized pack, exported CommonJS, and shared JSON fixtures through the same validator.
  • A generic schema becomes an accidental plugin API. Keep type handlers host-owned and reject functions, callbacks, and unknown fields.
  • A future mechanic needs richer state than scalar fields. Version the registry and require a mechanic-specific codec extension before content use.

Migration and rollback

  1. Add metadata validation and normalization with no existing mechanic entries.
  2. Add the engine envelope and lifecycle adapter with empty-mechanic parity.
  3. Connect restore, Undo, event cloning, pack/share, and solver registry tests.
  4. Add later gate and FRAIL behavior changes one at a time.

Rollback removes the foundation consumers and leaves existing packs valid. A later mechanic must not be enabled until this contract and its codec fixtures are present.

Open questions for later mechanic specs

  • Which static metadata fields identify entities, channels, or authored slots?
  • Which mechanics need beforeContacts versus afterContacts semantics?
  • Does a mechanic's state transition remain valid when a referenced object is transformed or destroyed?
  • What event payload is sufficient for presentation without exposing internal mutable state?
  • Which concrete mechanic types should be included in the host feature list?