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-share-level-packs / design.md
design.md 9.9K source

Context

The archived engine change provides three MIAU teaching levels, a validated local CommonJS loader, and a browser shell that currently treats loaded mod levels as one temporary list. This change turns authored content into a stable pack registry and adds two sharing paths: executable .cjs export/import for explicitly trusted local files, and a portable data-only URL that never evaluates code. The designer owns scenes and visual layout, so this design specifies content identity, data flow, and feedback semantics without prescribing panel placement or animation.

Goals / Non-Goals

Goals:

  • Give every shipped pack and level a stable identity independent of display titles or array positions.
  • Retain multiple bundled, URL, and explicitly trusted local packs in one session.
  • Ship a curated basic content set while preserving the original three MIAU teaching levels.
  • Export validated authored pack data as a deterministic .cjs module.
  • Share validated pack data through a versioned URL representation that performs no code execution.
  • Define atomic validation, collision, replacement, and user feedback behavior.
  • Verify registry behavior, browser controls, dynamic rule changes, mod loading, sharing, and undo together.

Non-Goals:

  • A visual redesign, scene graph, menu layout, animation language, or input ownership. Those decisions remain with the designer.
  • Server hosted packs, accounts, cloud persistence, analytics, or multiplayer sharing.
  • Executing JavaScript from a URL payload or treating a URL as a CommonJS module.
  • Sandboxing arbitrary untrusted CommonJS. Local CJS import remains an explicit trusted action.
  • Changing the engine's six properties, turn ordering, or rule vocabulary.

Decisions

1. Use pack id plus level id as the content address

manifest.id is the stable pack id. Each level gains a stable lowercase id unique within that pack. The registry key is { packId, levelId }; titles and array positions are display and ordering metadata only. A generated id is not acceptable for authored content because it would make URLs and exported files unstable.

2. Keep a session registry with origin metadata

The registry stores normalized packs in insertion order with origin bundled, url, or local. Registration validates the entire pack before mutation. A failed registration leaves the registry byte-for-byte unchanged. The active selection stores both pack and level ids and resolves the selected level through the engine's existing validation path.

3. Preserve the original teaching pack and add curated content as separate packs

The existing three MIAU levels become a named teaching pack with stable level ids. Additional basic packs are separate bundled records, so the teaching sequence remains stable and imported content cannot alter its order. The registry can later support authored progression metadata without coupling it to the engine.

4. Resolve pack collisions with Replace or an explicit fork

A duplicate pack id pauses for an explicit choice. Replace validates the complete candidate first, then swaps it atomically while retaining the authored id and progress key. Keep both as a copy chooses the next available suffixed id (-2, -3, …), records forkOf provenance, and starts progress under the new id. Level ids only need to be unique inside a pack; intro in two different packs is valid. This makes stale share links diagnosable and avoids silently replacing player content.

5. Serialize one canonical data model through both sharing paths

The canonical pack data contains manifest identity/version, ordered levels with ids and authored objects, vocabulary, and palette. It excludes current engine objects, move count, undo history, win state, and UI state. .cjs export prints deterministic CommonJS source that returns this data. URL sharing serializes the same data as canonical JSON inside a versioned miau1.<base64url>.<checksum> payload in the URL hash. The checksum catches accidental corruption; it does not authenticate authorship.

6. Keep URL decoding data-only and compatible

The share link is exactly #pack=<versioned-payload>, with a maximum encoded payload of 16 KiB. The URL path decodes bytes, parses JSON, checks the envelope version and size, and runs pack validation. Schema v1 is the only currently documented share format, so the decoder keeps v1 compatible across site revisions and rejects unknown future versions before registration; a later documented schema gets an explicit adapter and fixture at that time. It never calls the CommonJS loader, new Function, require, or any hook. A valid decoded pack is registered only after complete validation. This gives the user a compact portable link without turning links into executable content.

7. Make local CJS trust explicit

The local file control is separate from URL import and labels the operation as trusted code execution. It calls the existing CommonJS loader, validates its exported pack, and passes the normalized data to the same registry. Loader errors, unsupported imports, validation errors, and collisions are reported without discarding prior registry entries.

The URL decoder never falls through to this path. Conversely, a .cjs file is never treated as data-only URL content.

8. Normalize preview results and service failures

importPackText and decodePackLink return the same PackView shape used by listPacks, with pack identity, author, origin, level summaries, and the complete first level for the Gift or Workshop mini preview. They also return conflict: null or { id, name } when the candidate id is already registered; preview never mutates the registry. All player-facing hooks return { ok, ...value, errors } envelopes. Errors carry a readable message, raw detail for Copy details, and an optional levelId, so scenes can show the first five without parsing exception text. Missing export/link ids and attempts to remove bundled packs are normal error results, not throws.

9. Route player sharing through Gift and Workshop semantics

A valid #pack= hash becomes a pending Gift state. It does not install automatically; the player must choose Keep and play, after which the registry applies collision handling. Pack management is exposed through a presentation-independent service with the hook IDs listPacks, importPackText(text, filename), exportPack(id), packLink(id), decodePackLink(hash), installPack(pack, {replace}), and removePack(id). A Workshop scene can map these to Import, Play, Share link, Export, and Remove. Interim sidebar controls are developer-only behind ?dev; stage, scene, and layout work belongs in a separate change.

10. Use a semantic status channel without owning layout

Sharing and registry operations report readable success or failure text with pack identity where available. The designer can render these in the appropriate scene or menu. The contract requires status to remain understandable without animation, but leaves placement, typography, and transitions open.

11. Verify at data, engine, and browser boundaries

Unit tests cover canonical serialization, URL tampering, no-code-execution decoding, identity/collision rules, multi-pack retention, and export round trips. Engine fixtures continue to prove rule changes and undo. Browser checks cover built-in selection, keyboard/touch movement, sharing controls, trusted local import, URL import, error feedback, and preservation of existing packs.

Risks / Trade-offs

  • [Pack URLs become too long] → Use compact canonical JSON and base64url, enforce the 16 KiB encoded maximum, and fall back to .cjs export for larger packs.
  • [A pack id collision can hide intended content] → Reject by default, show both origins, and require explicit replacement with atomic validation.
  • [Exported source is executable when imported] → Keep .cjs import visibly separate from URL import and require an explicit trusted local action.
  • [A URL payload is mistaken for code] → Use a versioned data envelope and a decoder that has no CommonJS or dynamic evaluation path.
  • [A site revision breaks old links] → Keep explicit compatibility adapters for every still-documented payload version and test links across decoder revisions.
  • [Authored ids are missing from existing levels] → Add stable ids to bundled content before registry activation and reject imported levels without them.
  • [Generated ids collide across imports] → Require authored stable pack and level ids at validation time; never derive registry identity from array position or a generated fallback.
  • [Additional packs dilute the teaching sequence] → Keep the MIAU teaching pack ordered and marked as the default; expose other packs as separate selectable content.
  • [Feedback conflicts with designer scenes] → Specify semantic status events and accessible text only; leave visual placement and presentation to the designer.

Migration Plan

  1. Add stable pack and level ids to the existing three MIAU levels and normalize the current example mod shape.
  2. Introduce the registry and load bundled content through it before enabling imported packs.
  3. Retain the existing loadMod validator as the trusted local CJS execution boundary, adapting its normalized output to pack records.
  4. Add canonical export and data-only URL encode/decode, then register both through the same validation and collision pipeline.
  5. Update selection and feedback adapters without changing the engine turn API or designer-owned layout.
  6. If rollback is needed, disable share controls and registry imports; the bundled teaching pack and existing engine remain playable.

Open Questions

  • Should a future scene display replacement history and origin alongside pack credits? The current registry preserves the active level if its id survives replacement; otherwise it selects the replacement pack's first level.
  • Should the designer's map scene group Garden Basics and Word Lab by concept, or preserve the current flat level list within each pack?