clone

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

main
miau-is-true / openspec / changes / archive / 2026-09-23-build-dynamic-rule-engine / design.md
design.md 8.2K source

Context

This change establishes the first playable browser foundation in an otherwise small Vite project. The existing MVP direction is a deterministic JavaScript engine (src/engine.js), an emoji and text grid, built-in levels, and local CommonJS modules. The supplied design backbone calls for words as physical entities, straight horizontal or vertical rule scans, atomic pushes, derived properties, complete undo snapshots, and a narrow mod boundary. The implementation must remain small enough to validate through fixtures before adding the broader Baba Is You vocabulary.

Goals / Non-Goals

Goals:

  • Define a stable state model and turn pipeline for stacked entities and movable words.
  • Implement the six initial properties: YOU, PUSH, STOP, WIN, SINK, and DEFEAT.
  • Support IS, AND, noun-to-noun transformation, push chains, rule re-scan, undo, and reset.
  • Ship three built-in MIAU teaching puzzles with browser keyboard and touch controls.
  • Load validated local CommonJS manifests for nouns, emoji, and levels.
  • Keep rendering, Pretext effects, and feedback downstream of simulation state.

Non-Goals:

  • Full commercial Baba Is You compatibility, natural-language parsing, NOT, HAS, PULL, conditionals, autonomous movement, or recursive rule effects.
  • Diagonal movement, continuous physics, network play, persistence, or arbitrary UI-as-level semantics.
  • A secure sandbox for untrusted third-party JavaScript. Local mods are a trusted development/content boundary for this MVP.
  • Using text metrics or animation layout to determine grid coordinates.

Decisions

1. Keep simulation state separate from rendering

The engine owns immutable-by-convention state snapshots, entity identity, rule records, history, and effects. The UI subscribes to cloned state and event data. Renderers cannot mutate simulation objects. This keeps replay, undo, and deterministic fixtures possible. A DOM or canvas scene graph was considered, but it would couple layout and collision state too early.

2. Use a compact data model with stacked entities

Levels contain width, height, metadata, and an objects array. Each object has an id, coordinate, and either kind: "word", text or kind: "object", noun. Occupancy is derived by coordinate, so words and objects can share cells. Words are PUSH by default and are included in normal push-chain resolution.

3. Parse only straight NOUN IS VALUE lines with AND

Rule scanning examines left-to-right and top-to-bottom cardinal lines. AND expands adjacent nouns or values into multiple rule records. Values are either one of the six properties or a noun. Malformed lines are ignored. The active rule table is replaced atomically after word movement and after noun transformation. Each rule records source word IDs in reading order and an axis for board highlights. A map keyed by subject/value pair removes duplicate records while retaining deterministic discovery order and source occurrences.

4. Resolve one accepted cardinal intent as one transaction

The engine snapshots before movement, resolves controlled entities in a stable direction-aware order, recursively plans push chains, and commits only successful movement. It then re-scans rules, applies noun transformations, re-scans again, resolves sink/defeat contacts, evaluates win, and returns a settled event record. Every result uses kind: 'step' | 'undo' | 'reset'. Movement entries are { id, from, to, pushedBy }; direct movers use pushedBy: null, while each pushed entity names its immediate pusher. A physical block is { id, dir, blockerId, reason }, with blockerId: null at a boundary and reason: 'stop' | 'bounds'. Rule deltas are full records with subject, predicate, sourceIds, and axis. Destruction entries retain { id, noun|text, at, cause: 'sink' | 'defeat' }, and settled results include controlLost plus the final win state. Arrays preserve resolution order: movement, transformation, destruction, then win evaluation. A failed push returns kind: 'step', accepted: false, is a no-op, and does not consume a move. This order makes the consequences of editing a rule visible on the next settled state.

5. Make undo a snapshot stack

Successful moves push a deep clone of the complete pre-turn state. undo restores the last snapshot, including rules, effects, move count, win state, and control state, and returns kind: 'undo' with reverse movement and rule deltas. Reset rebuilds the original state, clears history, and returns kind: 'reset'. The initial MVP can cap history at a fixed size, provided the cap is documented and deterministic.

6. Keep the CommonJS manifest narrow

The host loads module.exports and validates an object with a manifest containing id, name, and semantic version, plus levels, optional vocabulary, and optional palette records. The host owns parsing, snapshots, input, rendering, and persistence. The browser loader allows only the documented engine helper import and does not resolve URLs or packages. Since local CommonJS is trusted in this MVP, isolation means registration and error containment rather than a security sandbox; a worker or restricted loader is a later extension.

7. Treat Pretext as UI text decoration

Pretext can animate variable-length hints, active-rule labels, and feedback. Grid cells use fixed integer coordinates and CSS or canvas cell dimensions. This avoids letting proportional emoji widths or changing text effects alter collisions.

8. Test behavior through engine fixtures and browser smoke checks

Small level fixtures cover parser expansion, push success/failure, transformations, each contact effect, rule edits, undo, reset, mod validation, and win progression. Browser checks cover loading built-ins, keyboard/touch intent routing, rule display, and readable fallback rendering. These tests verify contracts rather than implementation details.

Risks / Trade-offs

  • [Overlapping words create ambiguous sentences] → Scan only cardinal straight lines, require explicit IS, and deduplicate exact rule records; add fixtures for shared cells.
  • [Multiple YOU entities can produce order-dependent pushes] → Sort by direction projection and stable id, document the ordering, include pushedBy on each movement entry, and test multi-entity moves.
  • [Contact effects can conflict] → Use a fixed MVP order and explicit win/defeat behavior; postpone extensible priorities until a concrete level requires them.
  • [Event consumers can animate the wrong phase] → Preserve movement, transformation, destruction, and win ordering in the event contract and keep full rule source records for sentence bars.
  • [Trusted CommonJS can execute arbitrary browser code] → Keep the loader local and opt-in, validate manifests, isolate module errors, and document that untrusted mods are unsupported.
  • [Emoji widths and animation can harm readability] → Use fixed cells, text fallbacks, accessible labels, and reduced-motion-safe static feedback.
  • [A tiny vocabulary may constrain level design] → Preserve the manifest vocabulary extension point and add capabilities only with new fixtures and OpenSpec changes.

Migration Plan

  1. Preserve the current engine API while adding fixtures around its state and turn behavior.
  2. Keep the three built-in MIAU levels and the browser shell consuming getState, step, undo, and reset.
  3. Keep the local CommonJS loader behind an explicit file selection path; a loader failure leaves built-ins available.
  4. Add the emoji/text renderer and active-rule panel, then run build, engine tests, and browser smoke checks.
  5. If the rule grammar or mod contract changes, update the relevant delta spec before changing implementation.

Rollback is deleting or disabling the new app shell and mod registration path while retaining the engine fixtures; no external data migration is required.

Open Questions

  • Should failed movement remain a no-op forever, or become a counted wait in a later ruleset?
  • What exact presentation should represent several stacked entities in one fixed cell?
  • Should a SINK contact always remove the sink entity itself, or can a future level opt into selective consumption?
  • When a turn creates both WIN and DEFEAT, should the level-specific policy be exposed before adding more effects?