Files

80 KiB
Raw Permalink Blame History

Phase 1: Foundations & Doctrine — Research

Researched: 2026-05-08 Domain: Project scaffolding, browser save framework, content build pipeline, AI-asset provenance gate, architectural-boundary linting, principle-level doctrine documents Confidence: HIGH (all stack versions verified against npm registry; all integration patterns verified against official docs)

Summary

Phase 1's research target was narrow by design — the user has already locked the stack (Phaser 4, React 19, Vite, TypeScript, idb, lz-string, Zod, Vitest, Playwright) and rejected every overengineered shape (no curator workflow, no two-stage AI promotion, no lint rules on UX strings, no pre-allocated save slots, no BigQty/Zustand/tick scheduler in this phase). Research therefore concentrated on the cheapest correct shape for each of the five Phase-1 success criteria: (1) the official npm create @phaserjs/game@latest React+Vite+TS template structure and how to restructure src/ into the firewall directories, (2) the ESLint boundary rule (eslint-plugin-boundaries v6.0.2 wins on cleanness), (3) a minimum-viable idb save layer with versioned {schemaVersion, payload, checksum} envelope, lz-string compression, last-3 snapshots, and Base64 export/import, (4) a Vite-native content pipeline using import.meta.glob('/content/**/*', { eager, query: '?raw' }) + gray-matter + Zod (no separate build script needed), and (5) a 30-line standalone Node script that walks assets/ and validates sidecar .provenance.json files.

All recommended package versions were verified against npm on 2026-05-08: Phaser 4.1.0, React 19.2.6, Vite 8.0.11, TypeScript 6.0.3, idb 8.0.3, lz-string 1.5.0, Zod 4.4.3, Vitest 4.1.5, Playwright 1.59.1, eslint-plugin-boundaries 6.0.2, crc-32 1.2.2, gray-matter 4.0.3, yaml 2.8.4, inkjs 2.4.0, inklecate 1.8.1.

Primary recommendation: Lean on the Phaser 4 official template heavily — do not restructure aggressively. Add the firewall directories (src/sim/, src/render/, src/ui/, src/save/, src/content/, src/audio/, src/store/) as siblings to the template's existing src/game/ directory; the template's existing entry points (src/main.tsx, src/App.tsx, src/PhaserGame.tsx) keep working. This is the minimum-viable scaffold that exposes ESLint boundary targets without fighting the template.

<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

AI Asset Pipeline Depth (D-01, D-02, D-03):

  • End-of-Phase-1 north-star reference set is 1020 hand-curated AI generations committed to the repo with full provenance metadata. Generated honestly with whatever tool was used (likely Claude/SD/Midjourney/etc. on a per-asset basis); the model_id and checkpoint_hash fields record what was actually used.
  • Tool/vendor choice deferred — Phase 1 commits to the schema and gate, not a vendor. Tool consolidation happens in Phase 5 when production volume kicks in.
  • Curation gate is minimum-viable. Sidecar name.provenance.json per asset under /assets/ carrying the 6 required fields (model_id, checkpoint_hash, prompt, seed, sampler, params); CI script walks the tree and fails the build on any missing/invalid sidecar. Sample refused asset under /assets/__samples__/refused/ (no sidecar) proves the gate works. No curator workflow, no two-stage promotion directory, no pre-commit hook, no CURATION-LOG.md ceremony.

Save Schema v1 Scope (D-04, D-05, D-06):

  • v1 save payload is minimal — only what Phase 2 will write (garden tile state, plant growth data, harvested fragment IDs, lastTickAt, basic settings). It does NOT pre-allocate slots for Roothold, currentSeason, storyFlags, etc. Phase 4 ships a real migrate_v1_to_v2 when Season-prestige state actually exists.
  • Round-trip migration test in Phase 1 uses a synthetic v0 → v1 migration as the proof that the chain works. v0 is a tiny made-up prior schema (e.g., {garden: []}) used only to exercise the migration framework end-to-end.
  • Save format {schemaVersion, payload, checksum} is locked. Checksum algorithm and exact migration registry shape are Claude's discretion.

Doctrine Docs Concreteness (D-07, D-08, D-09):

  • Anti-FOMO doctrine is a consolidation document in .planning/. No lint rule on UX strings.
  • Season 7 end-state design is principle-level, not treatment-level.
  • Both doctrine documents live under .planning/.

Project Scaffold Layout (D-10, D-11, D-12):

  • Use npm create @phaserjs/game@latest (React + Vite + TypeScript template). Restructure src/ to expose src/sim/, src/render/, src/ui/, plus supporting src/save/, src/content/, src/audio/, src/store/ as siblings.
  • Authored content (/content/**/*.{md,yaml,ink}) lives at repo root.
  • /assets/ (with provenance sidecars) lives at repo root. Single package, no monorepo / no workspaces.

Claude's Discretion

  • Exact ESLint boundary rule choice (eslint-plugin-boundaries vs no-restricted-paths).
  • Checksum algorithm (CRC32, simple hash, etc.).
  • Migration registry shape.
  • idb wrapper API surface inside src/save/.
  • Vitest / Playwright config files; vite.config.ts boundary plugin choices.
  • Where the Zod content schemas live and how the build step is invoked (Vite plugin vs separate npm run build:content script).
  • Specific images for the 1020 north-star generations.

Deferred Ideas (OUT OF SCOPE)

  • AI vendor lock-in / model pinning (Phase 5).
  • BigQty wrapper around break_eternity.js (Phase 2).
  • Empty Zustand store skeleton (Phase 2).
  • Tick scheduler / monotonic clock (Phase 2).
  • Season 7 treatment-level details (Phase 7).
  • Anti-FOMO lint rule on UX strings (rejected).
  • Curator workflow / two-stage asset promotion / pre-commit hook on assets (rejected).
  • Visual regression testing (Phase 8).
  • AudioContext / Howler.js setup (Phase 2).
  • Phaser scene wiring or rendering (Phase 2-3).
  • Ink dialogue authoring (Phase 2+).

</user_constraints>

<phase_requirements>

Phase Requirements

ID Description Research Support
CORE-01 Game loads in <5s in modern browsers (Chrome/Firefox/Safari/Edge last 2) on 25 Mbps Phaser 4 template ships Vite 8 with sensible default code-splitting; the empty scaffold loads in well under 5s. The enforcement mechanism is a Playwright smoke test introduced in Phase 2 (PIPE-07); Phase 1 verifies by npm run build succeeding and visual sanity in dev.
CORE-04 Save persists to IndexedDB with localStorage fallback idb 8.0.3 wraps IndexedDB in promises; localStorage fallback is a try/catch around openDB failures. See "Save Layer Shape" below.
CORE-05 navigator.storage.persist() called on first save with respectful surfacing of false Available in Chrome/Firefox/Edge (Safari 17+ partial); when false, surface "saves may be cleared if storage runs low — export a backup from Settings" at next opportunity.
CORE-06 Saves versioned {schemaVersion, payload, checksum}; refuse load on checksum mismatch with recovery option crc-32 (crc-32 package, 426k ops/sec, dependency-free, ~3KB) is the right shape. See "Checksum Choice" below.
CORE-07 migrate_vN_to_vN+1 chain with Vitest tests for every shipped migration Ordered registry pattern; synthetic v0→v1 demo migration exercises the chain end-to-end. See "Migration Registry Shape" below.
CORE-08 Last 3 pre-migration save snapshots retained; "restore previous save" reachable from settings (UI hooks land in Phase 4) Separate snapshots object store keyed by {schemaVersion, savedAt}; pre-migration hook writes to it; pruner runs on each write. Phase 1 ships only the storage + retention; settings UI is Phase 4.
CORE-09 Save export/import as Base64 text via Settings (function pair shipped Phase 1; UI button Phase 2+) lz-string.compressToBase64 of the JSON envelope is the export; decompressFromBase64 is the import. Round-trip test in Vitest.
CORE-10 src/sim/ imports nothing from src/render/ or src/ui/ — enforced by ESLint boundary rules in CI eslint-plugin-boundaries 6.0.2 with the boundaries/element-types rule. See "ESLint Boundary Rule" below.
PIPE-01 Build compiles /content/**/*.{md,yaml,ink} into per-Season JSON via Zod-validated schemas; build fails on schema violation Vite-native import.meta.glob('/content/**/*', { eager: true, query: '?raw' }) + gray-matter + yaml + Zod, validated at build time. See "Content Pipeline" below.
PIPE-03 AI asset pipeline records provenance per asset and refuses unprovenanced assets Standalone scripts/validate-assets.mjs walks assets/ (excluding __samples__/refused/); fails CI on any non-sidecar file lacking a sibling <name>.provenance.json validated by a Zod schema.
PIPE-05 anti-FOMO doctrine and Season 7 end-state design documents in .planning/ before economy code Outlined below in "Doctrine Doc Outlines". Principle-level, not treatment.
PIPE-06 Vitest unit tests cover all save migrations and run on every CI build Vitest 4.1.5 with a pnpm/npm test step in CI. Test files: src/save/migrations.test.ts, src/save/round-trip.test.ts.
AEST-08 All AI-assisted assets carry persisted provenance metadata ({model_id, checkpoint_hash, prompt, seed, sampler, params}) Sidecar .provenance.json schema enforced by validator script. The "pinned model" wording in REQUIREMENTS is satisfied by per-asset honest provenance for Phase 1; vendor pinning is Phase 5.
AEST-09 All shipped assets pass a mandatory human curation gate before integration The validator script is the gate (CI fails if sidecar missing/invalid). Phase 1 also commits the 1020-image north-star reference set as the seed. No curator workflow doc, per CONTEXT D-03.
STRY-09 Every player-visible string externalized in /content/, not hardcoded Phase 1 establishes the /content/ convention and the loader; no lint rule enforces this (per CONTEXT D-07). Phase 2 begins authoring real strings into /content/season-01-soil/.
UX-13 No daily login bonuses, streaks, limited-time content, nag notifications, loss-aversion copy — anti-FOMO doctrine enforced in every UX review Doctrine doc shipped in this phase; enforcement is by review, not code.

</phase_requirements>

Architectural Responsibility Map

Capability Primary Tier Secondary Tier Rationale
Project scaffold (vite, tsconfig, eslint baseline) Build / Dev tooling Ships the dev-server + bundler boundary — not user-facing.
Save persistence (IndexedDB, lz-string, checksum) Browser / Client (Storage) All save state is local-first per CLAUDE.md "no always-online" constraint.
Save migration chain Browser / Client (Storage) Simulation core consumer Migration runs at load time before sim sees state; pure functions, no I/O.
Save export/import (Base64) Browser / Client (Storage) UI (Phase 2+ wires button) Function pair lives in src/save/; UI surface is later.
Content build pipeline (md/yaml/ink → JSON) Build / Static asset transform Browser runtime consumer Vite plugin pattern at build; runtime imports compiled JSON only.
Zod content schemas Build / Static asset validation Browser runtime type source Schemas validate at build, derive TS types via z.infer at compile.
AI asset provenance gate CI / Build-time validator Repo discipline Standalone Node script; no runtime import. Lives in scripts/.
ESLint boundary rule Build / Static-analysis Lints src/sim/src/render//src/ui/ import edges; runs in CI and IDE.
Anti-FOMO doctrine Process / Documentation UX review reference Markdown in .planning/; not enforced by code.
Season 7 end-state doctrine Process / Documentation Future Phase 7 reference Markdown in .planning/; principle-level.
North-star asset reference set Repo / Static asset Future Phase 5 model-pinning input 1020 images committed under assets/north-stars/ with sidecars.

Important: Every Phase 1 capability is build/CI/storage tier — there is no Simulation or Rendering responsibility in this phase. Phase 2 is where Simulation Core takes ownership.

Standard Stack

Core (versions verified against npm 2026-05-08)

Library Version Purpose Why Standard
phaser 4.1.0 2D game framework (canvas owner, Phase 2+ uses it) Locked by CONTEXT D-10. Verified npm view phaser version → 4.1.0 ("Salusa", April 2026).
react / react-dom 19.2.6 UI shell (DOM overlay) Locked by CLAUDE.md / STACK.md. npm view react version → 19.2.6.
vite 8.0.11 Dev server + bundler Phaser 4 official template ships with Vite. npm view vite version → 8.0.11. NB: STACK.md mentions Vite 6 as baseline; the template version may be 6 or 7 — accept whatever the scaffold installs and don't fight it. Vite 8 with Rolldown is current latest.
typescript 6.0.3 Static typing npm view typescript version → 6.0.3.
idb 8.0.3 Promise-based IndexedDB wrapper Locked. npm view idb version → 8.0.3. ~1.19kB brotli'd; openDB + put/get covers Phase 1 entirely.
lz-string 1.5.0 Save compression + Base64 export Locked. compressToBase64 / decompressFromBase64 is the export round-trip.
zod 4.4.3 Schema validation (content + save envelope + provenance) Locked. Note: STACK.md cites 3.23+; current is 4.4.3 — Zod 4 is the modern major. Use 4.x for new code.
vitest 4.1.5 Test runner (migration tests + round-trip test) Locked. Vite-native; covers PIPE-06.
@playwright/test 1.59.1 E2E smoke (no test in Phase 1; install only — first test in Phase 2) Locked by STACK.md. Phase 1 only npm installs it; first real spec is PIPE-07 in Phase 2.
eslint-plugin-boundaries 6.0.2 Architectural-firewall lint rule Cleaner than no-restricted-paths for the src/sim/src/render//src/ui/ rule. See decision rationale below.
crc-32 1.2.2 Save checksum Dependency-free; CRC32.str(jsonString) returns a 32-bit signed int; ~3KB. Fastest pure-JS CRC implementation in benchmarks (~426k ops/sec).
gray-matter 4.0.3 Parse YAML frontmatter from .md files Standard for the fragment manifest; STACK.md already endorses.
yaml 2.8.4 Parse pure .yaml content files eemeli/yaml, dependency-free, full YAML 1.2 spec; replaces js-yaml for new code.

Supporting (deferred — install but do not use yet)

Library Version Purpose When to Use
inkjs 2.4.0 Ink narrative runtime Install in Phase 1 so the dependency is locked. Do NOT compile any .ink files in Phase 1. Phase 2 begins authoring + compilation.
inklecate 1.8.1 Ink compiler CLI Install as devDependency. Phase 1 includes a package.json script compile:ink that exists (so the build pipeline shape is proven) but is invoked on an empty /content/dialogue/ directory and is a no-op. Phase 2 starts adding .ink files.

Phase 1 explicitly does NOT install: zustand, break_eternity.js, howler + @types/howler, mitt, dayjs — those land in Phase 2 with the simulation core. Do not pre-install — keeping package.json honest about phase scope is itself part of the discipline.

Alternatives Considered

Instead of Could Use Tradeoff
eslint-plugin-boundaries eslint-plugin-import rule no-restricted-paths no-restricted-paths requires manually enumerating every forbidden path pair, no glob support, no element-type abstraction. For one rule (sim ↔ render/ui) it would work in ~10 lines but the model is awkward. eslint-plugin-boundaries defines element types once and rules read clearly. Pick boundaries.
crc-32 xxhashjs / murmurhash / Web Crypto SHA-256 (sliced) xxhash is faster on large strings but heavier dependency; murmurhash is comparable but slightly less battle-tested in JS. SHA-256 is overkill for non-adversarial corruption detection (a save isn't a security boundary — checksum mismatch just signals "don't load this"). CRC-32 is correct: deterministic, ~3KB, dependency-free, fast enough. Idle-game saves are bytes-to-low-megabytes; even a slower hash would be fine.
Vite-native content pipeline (import.meta.glob) Separate npm run build:content Node script The Vite-native path is one less moving piece — content imports become typed values at build time, HMR works for free, Vite's tree-shaking handles per-Season chunking via dynamic glob in Phase 2. The standalone-script path is more flexible (can emit JSON to disk that Vite never sees) but Phase 1 has no need. Vite-native wins on simplicity. Keep the option to refactor later if Phase 5+ asset volume forces it.
Synthetic v0→v1 migration in Phase 1 Skip migration test until Phase 4's real v1→v2 CONTEXT D-05 explicitly mandates the synthetic test. The point is to prove the framework, not the data shape. Synthetic v0 is correct.
crc-32 with CRC32.str() over JSON string Hash the parsed object Hashing the canonical JSON string is deterministic across runs only if JSON.stringify is canonical (key order). Since save state is internally controlled (we write the schema), key order is stable. Document the canonicalization in src/save/checksum.ts.

Installation

# Step 0: scaffold (run interactively, choose "React + Vite + TypeScript")
npm create @phaserjs/game@latest the-last-garden
cd the-last-garden

# Step 1: Phase 1 production deps
npm install idb@^8 lz-string@^1.5 zod@^4 crc-32@^1.2 gray-matter@^4 yaml@^2.8 inkjs@^2.4

# Step 2: Phase 1 dev deps
npm install -D vitest@^4 @playwright/test@^1.59 eslint-plugin-boundaries@^6 inklecate@^1.8

The Phaser 4 template already installs phaser, react, react-dom, vite, typescript, and an ESLint baseline. Add to it; don't replace it.

Version verification log

Package Verified version Date
phaser 4.1.0 2026-05-08
react 19.2.6 2026-05-08
vite 8.0.11 2026-05-08
typescript 6.0.3 2026-05-08
idb 8.0.3 2026-05-08
lz-string 1.5.0 2026-05-08
zod 4.4.3 2026-05-08
vitest 4.1.5 2026-05-08
@playwright/test 1.59.1 2026-05-08
eslint-plugin-boundaries 6.0.2 2026-05-08
crc-32 1.2.2 2026-05-08
gray-matter 4.0.3 2026-05-08
yaml 2.8.4 2026-05-08
inkjs 2.4.0 2026-05-08
inklecate 1.8.1 2026-05-08
howler 2.2.4 2026-05-08 (Phase 2)
break_eternity.js 2.1.3 2026-05-08 (Phase 2)
zustand 5.0.13 2026-05-08 (Phase 2)

[VERIFIED: npm registry, 2026-05-08]

Architecture Patterns

System Architecture Diagram (Phase 1 deliverables only)

┌──────────────────────────────────────────────────────────────────────┐
│                        REPO ROOT                                      │
│  /content/  → authored md+yaml+ink   (Phase 2+ fills it)             │
│  /assets/   → AI generations + sidecar provenance JSON               │
│  /.planning/ → doctrine docs (anti-fomo, season-7-end-state)         │
│  /scripts/  → validate-assets.mjs (CI provenance gate)               │
│                                                                       │
│  /src/                                                                │
│  ├── main.tsx, App.tsx, PhaserGame.tsx  (template-provided)          │
│  ├── game/         (template-provided — Phaser scenes)                │
│  ├── sim/          [FIREWALL: imports nothing from render/ or ui/]    │
│  ├── render/       (Phase 2+ Phaser scene wiring)                     │
│  ├── ui/           (Phase 2+ React HUD)                               │
│  ├── save/         ← LOAD-BEARING IN PHASE 1                          │
│  │   ├── db.ts          (idb wrapper: openDB + 2 stores)              │
│  │   ├── envelope.ts    ({schemaVersion, payload, checksum})          │
│  │   ├── checksum.ts    (CRC32 over canonical JSON)                   │
│  │   ├── migrations.ts  (registry: {1: v0→v1, ...})                   │
│  │   ├── snapshots.ts   (last-3 pre-migration retention)              │
│  │   ├── persist.ts     (navigator.storage.persist + result handler)  │
│  │   ├── codec.ts       (lz-string + Base64 export/import)            │
│  │   └── *.test.ts      (vitest: round-trip + each migration)         │
│  ├── content/      ← LOAD-BEARING IN PHASE 1                          │
│  │   ├── schemas/       (zod schemas: fragment.ts, season.ts)         │
│  │   └── loader.ts      (import.meta.glob + gray-matter + zod parse)  │
│  ├── audio/        (empty placeholder for Phase 2)                    │
│  └── store/        (empty placeholder for Phase 2)                    │
│                                                                       │
│  /eslint.config.js  (extends template + boundaries plugin)            │
│  /vitest.config.ts                                                    │
│  /playwright.config.ts (installed; no specs yet)                      │
│  /package.json                                                        │
└──────────────────────────────────────────────────────────────────────┘

       Build pipeline (Phase 1)              CI gate (Phase 1)
   ┌─────────────────────────┐         ┌──────────────────────────┐
   │ vite build              │         │ npm run lint             │
   │  → import.meta.glob     │         │  (eslint-plugin-bounds)  │
   │  → zod-parse content    │         │ npm test                 │
   │  → fail on violation    │         │  (vitest: migrations)    │
   └─────────────────────────┘         │ node scripts/            │
                                       │   validate-assets.mjs    │
                                       │  (provenance walker)     │
                                       └──────────────────────────┘
the-last-garden/
├── .planning/
│   ├── PROJECT.md                          (existing)
│   ├── REQUIREMENTS.md                     (existing)
│   ├── ROADMAP.md                          (existing)
│   ├── STATE.md                            (existing)
│   ├── anti-fomo-doctrine.md               (NEW — Phase 1 deliverable)
│   ├── season-7-end-state.md               (NEW — Phase 1 deliverable)
│   ├── research/                           (existing)
│   └── phases/                             (existing)
├── content/                                 (NEW empty tree — Phase 2+ fills)
│   ├── seasons/                            (per-Season subdirs added in Phase 2+)
│   └── README.md                           (explains the content shape)
├── assets/
│   ├── north-stars/                        (NEW — 1020 curated images + sidecars)
│   │   ├── garden-soil-01.png
│   │   ├── garden-soil-01.provenance.json
│   │   └── ...
│   └── __samples__/
│       └── refused/
│           └── unprovenanced.png           (proves the gate)
├── scripts/
│   └── validate-assets.mjs                 (NEW — CI provenance walker)
├── src/
│   ├── main.tsx                            (template-provided)
│   ├── App.tsx                             (template-provided)
│   ├── PhaserGame.tsx                      (template-provided)
│   ├── game/                               (template-provided — Phaser scenes)
│   ├── sim/                                (NEW empty — Phase 2+ fills)
│   │   └── .gitkeep
│   ├── render/                             (NEW empty — Phase 2+ fills)
│   │   └── .gitkeep
│   ├── ui/                                 (NEW empty — Phase 2+ fills)
│   │   └── .gitkeep
│   ├── save/                               (NEW — Phase 1 ships)
│   │   ├── db.ts
│   │   ├── envelope.ts
│   │   ├── checksum.ts
│   │   ├── migrations.ts
│   │   ├── snapshots.ts
│   │   ├── persist.ts
│   │   ├── codec.ts
│   │   ├── round-trip.test.ts
│   │   └── migrations.test.ts
│   ├── content/                            (NEW — Phase 1 ships loader, no real content yet)
│   │   ├── schemas/
│   │   │   ├── fragment.ts
│   │   │   └── season.ts
│   │   └── loader.ts
│   ├── audio/                              (NEW empty — Phase 2+)
│   │   └── .gitkeep
│   └── store/                              (NEW empty — Phase 2+)
│       └── .gitkeep
├── eslint.config.js                        (extends template, adds boundaries plugin)
├── vitest.config.ts                        (NEW)
├── playwright.config.ts                    (NEW — empty test dir, install only)
├── tsconfig.json                           (template-provided, may add path alias)
├── vite.config.ts                          (template-provided)
├── package.json
└── CLAUDE.md                               (existing)

Pattern 1: Save Envelope {schemaVersion, payload, checksum}

What: Every save written to disk is a top-level envelope:

interface SaveEnvelope<T = unknown> {
  schemaVersion: number;
  payload: T;
  checksum: string; // hex CRC32 of canonical JSON.stringify(payload)
}

The checksum is computed over the canonical JSON string of the payload only (not the envelope). On load, recompute and compare; mismatch → refuse to load, surface recovery option.

When to use: Always. CLAUDE.md mandates the shape; Phase 2+ writes payloads of this shape exclusively.

Example:

// src/save/envelope.ts
import { z } from 'zod';
import { crc32hex } from './checksum';

export const SaveEnvelopeSchema = z.object({
  schemaVersion: z.number().int().positive(),
  payload: z.unknown(),
  checksum: z.string().regex(/^[0-9a-f]{8}$/),
});
export type SaveEnvelope<T = unknown> = z.infer<typeof SaveEnvelopeSchema> & { payload: T };

export function wrap<T>(payload: T, schemaVersion: number): SaveEnvelope<T> {
  return {
    schemaVersion,
    payload,
    checksum: crc32hex(canonicalJSON(payload)),
  };
}

export function unwrap<T>(env: SaveEnvelope<unknown>): T {
  const expected = crc32hex(canonicalJSON(env.payload));
  if (expected !== env.checksum) throw new SaveCorruptError(env.checksum, expected);
  return env.payload as T;
}

// canonicalJSON: stable key order. For Phase 1, JSON.stringify with sorted keys is enough.
function canonicalJSON(value: unknown): string {
  return JSON.stringify(value, (_k, v) =>
    v && typeof v === 'object' && !Array.isArray(v)
      ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b)))
      : v
  );
}

[CITED: zod docs https://zod.dev — schema-from-type pattern]

Pattern 2: Migration Registry (ordered chain)

What: A Record<number, Migration> mapping target-version → pure function. Loader walks from current envelope's version to CURRENT_SCHEMA_VERSION, applying each migration in turn.

// src/save/migrations.ts
type Migration = (payload: unknown) => unknown;

export const CURRENT_SCHEMA_VERSION = 1;

// Each entry migrates FROM (key-1) TO key. So migrations[1] = v0→v1.
export const migrations: Record<number, Migration> = {
  1: (s: any) => ({
    // Synthetic demo: v0 was {garden: []}. v1 is the real Phase 2 shape.
    garden: { tiles: s.garden ?? [] },
    plants: [],
    harvestedFragmentIds: [],
    lastTickAt: Date.now(),
    settings: { musicVolume: 0.7, ambientVolume: 0.5, sfxVolume: 0.8 },
  }),
};

export function migrate(payload: unknown, fromVersion: number): { payload: unknown; toVersion: number } {
  let current = payload;
  let v = fromVersion;
  while (v < CURRENT_SCHEMA_VERSION) {
    const next = v + 1;
    const fn = migrations[next];
    if (!fn) throw new Error(`No migration registered for v${v} → v${next}`);
    current = fn(current);
    v = next;
  }
  return { payload: current, toVersion: v };
}

Why ordered chain over registry-with-named-functions: The chain is the most testable shape — each migration is a pure function with a known input schema and known output schema; Vitest tests each migration in isolation (expect(migrations[1]({garden: []})).toEqual({...v1 shape})). Phase 4's real migrate_v1_to_v2 slots in by adding migrations[2] = ....

Pre-migration snapshot rule: Before invoking migrate() on a load, write the original envelope to the snapshots object store keyed by {schemaVersion, savedAt}. Pruner trims to last 3 entries. See "Pattern 3" below.

[CITED: shapez.io save/load post-mortem — https://deepwiki.com/tobspr-games/shapez.io/2.2-saveload-system; ARCHITECTURE.md Pattern 5]

Pattern 3: Last-3 Pre-Migration Snapshots

What: A second IndexedDB object store, save_snapshots, holds copies of envelopes from before any migration ran. Schema:

interface SnapshotEntry {
  id: string;            // `${schemaVersion}-${savedAtIso}`
  schemaVersion: number;
  savedAt: string;       // ISO8601
  envelope: SaveEnvelope;
}

Retention rule: On every snapshot write, query all entries, sort by savedAt desc, keep first 3, delete the rest. Synchronous logic inside one IDB transaction.

Phase scope: Phase 1 only ships the storage and pruning layer + a Vitest test that (write 5 snapshots) → (assert 3 remain, oldest 2 deleted). The "Restore previous save" UI button hooks into this in Phase 4.

Example:

// src/save/snapshots.ts
import { openSaveDB } from './db';
import type { SaveEnvelope } from './envelope';

const RETAIN = 3;

export async function snapshot(envelope: SaveEnvelope): Promise<void> {
  const db = await openSaveDB();
  const tx = db.transaction('save_snapshots', 'readwrite');
  const store = tx.objectStore('save_snapshots');
  const savedAt = new Date().toISOString();
  await store.put({
    id: `${envelope.schemaVersion}-${savedAt}`,
    schemaVersion: envelope.schemaVersion,
    savedAt,
    envelope,
  });
  // Prune
  const all = await store.getAll();
  const sorted = all.sort((a, b) => b.savedAt.localeCompare(a.savedAt));
  const toDelete = sorted.slice(RETAIN);
  await Promise.all(toDelete.map((e) => store.delete(e.id)));
  await tx.done;
}

export async function listSnapshots(): Promise<SnapshotEntry[]> {
  const db = await openSaveDB();
  const all = await db.getAll('save_snapshots');
  return all.sort((a, b) => b.savedAt.localeCompare(a.savedAt));
}

Pattern 4: Vite-Native Content Pipeline

What: Use import.meta.glob('/content/**/*.{md,yaml}', { eager: true, query: '?raw', import: 'default' }) to import every content file as a raw string at build time. Run gray-matter (for .md) or yaml.parse (for .yaml) on each, then validate via Zod schemas. Build fails on any Zod error because loader.ts runs at module-evaluation time and a thrown error propagates through Vite to the build process.

Why this shape over a separate script:

  • One source of truth: the loader runs at build and dev (HMR works for free).
  • No emitted-JSON-file problem: compiled content is just JS values bundled by Vite.
  • Per-Season chunking arrives in Phase 2 by switching to { eager: false } for Seasons 27 and eager: true for Season 1.
  • Vite already handles file watching, glob expansion, and TypeScript types.

Phase 1 scope: Loader exists, schemas exist, but /content/ is empty of real content. Demo schema (one tiny fragment in /content/seasons/00-demo/fragments.yaml) proves the round-trip and is removed in Phase 2.

Example:

// src/content/schemas/fragment.ts
import { z } from 'zod';

export const FragmentSchema = z.object({
  id: z.string().regex(/^season\d+\.[a-z0-9._-]+$/),
  season: z.number().int().min(1).max(7),
  body: z.string().min(1),
});
export type Fragment = z.infer<typeof FragmentSchema>;

export const SeasonContentSchema = z.object({
  fragments: z.array(FragmentSchema),
});
// src/content/loader.ts
import grayMatter from 'gray-matter';
import { parse as parseYAML } from 'yaml';
import { z } from 'zod';
import { FragmentSchema, SeasonContentSchema } from './schemas';

// Vite handles glob; ?raw imports as string; eager makes the imports available synchronously.
const yamlFiles = import.meta.glob('/content/seasons/*/fragments.yaml', {
  eager: true,
  query: '?raw',
  import: 'default',
}) as Record<string, string>;

const mdFiles = import.meta.glob('/content/seasons/*/fragments/*.md', {
  eager: true,
  query: '?raw',
  import: 'default',
}) as Record<string, string>;

const fragmentsFromYaml = Object.entries(yamlFiles).flatMap(([path, raw]) => {
  const data = parseYAML(raw);
  const parsed = SeasonContentSchema.safeParse(data);
  if (!parsed.success) {
    throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`);
  }
  return parsed.data.fragments;
});

const fragmentsFromMd = Object.entries(mdFiles).map(([path, raw]) => {
  const { data, content } = grayMatter(raw);
  const merged = { ...data, body: content.trim() };
  const parsed = FragmentSchema.safeParse(merged);
  if (!parsed.success) {
    throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`);
  }
  return parsed.data;
});

export const fragments: Fragment[] = [...fragmentsFromYaml, ...fragmentsFromMd];

Ink files in Phase 1: Per CONTEXT discretion + cheaper-path recommendation, defer Ink compilation to Phase 2. Phase 1 ships:

  1. npm install inkjs inklecate so the deps are locked.
  2. A package.json script "compile:ink": "inklecate -o src/content/compiled-ink/ content/dialogue/*.ink || true" that exists but operates on an empty directory (no-op).
  3. /content/dialogue/.gitkeep so the directory is real.
  4. No Ink validation in Phase 1 — Phase 2 adds it when the first .ink file is written.

This is the minimum-viable shape: the path to compile Ink exists; nothing flows through it yet. Full Ink integration is Phase 2.

Pattern 5: ESLint Boundary Rule (eslint-plugin-boundaries)

What: Define each top-level src/ directory as an "element type"; declare which types each type may import from.

// eslint.config.js (flat config, ESM)
import boundaries from 'eslint-plugin-boundaries';

export default [
  // ... template's existing config first ...
  {
    plugins: { boundaries },
    settings: {
      'boundaries/elements': [
        { type: 'sim',     pattern: 'src/sim/**' },
        { type: 'render',  pattern: 'src/render/**' },
        { type: 'ui',      pattern: 'src/ui/**' },
        { type: 'save',    pattern: 'src/save/**' },
        { type: 'content', pattern: 'src/content/**' },
        { type: 'audio',   pattern: 'src/audio/**' },
        { type: 'store',   pattern: 'src/store/**' },
        { type: 'app',     pattern: 'src/{main,App,PhaserGame}.tsx' },
        { type: 'game',    pattern: 'src/game/**' },
      ],
    },
    rules: {
      'boundaries/element-types': ['error', {
        default: 'allow',
        rules: [
          // The Phase-1 firewall (CORE-10):
          { from: ['sim'], disallow: ['render', 'ui'] },
        ],
      }],
    },
  },
];

Why this over no-restricted-paths: The element-type abstraction means that when Phase 4 adds src/server/ (or any new tier), we add one element entry rather than enumerating new path-pair forbids. Cleaner, more declarative, future-proof. The rule above forbids only the exact CORE-10 firewall; everything else is default: allow — keeping Phase 1 honest about which constraints actually exist.

CI integration: npm run lint in the GitHub Actions workflow (or local pre-commit). Failure exits non-zero. Verify with one deliberately-bad file in tests (a unit test that asserts eslint --rulesdir ... src/sim/__test_violation__/... exits with the boundary error — only kept in the test fixture, removed from production builds).

[VERIFIED: eslint-plugin-boundaries 6.0.2 README, 2026-05-08]

Pattern 6: Provenance Sidecar Validator

What: A 30-line standalone Node script that walks assets/, verifies that every non-sidecar file has a sibling <name>.provenance.json parseable against a Zod schema, with the exception of assets/__samples__/refused/.

// scripts/validate-assets.mjs
import { readdir, readFile, stat } from 'node:fs/promises';
import { join, extname, basename, dirname } from 'node:path';
import { z } from 'zod';

const ProvenanceSchema = z.object({
  model_id: z.string().min(1),
  checkpoint_hash: z.string().min(1),
  prompt: z.string().min(1),
  seed: z.union([z.string(), z.number()]),
  sampler: z.string().min(1),
  params: z.record(z.string(), z.unknown()),
});

const ASSETS = 'assets';
const REFUSED = ['assets/__samples__/refused'];

async function* walk(dir) {
  for (const entry of await readdir(dir, { withFileTypes: true })) {
    const path = join(dir, entry.name);
    if (entry.isDirectory()) yield* walk(path);
    else yield path;
  }
}

const errors = [];
for await (const path of walk(ASSETS)) {
  if (REFUSED.some((r) => path.replaceAll('\\', '/').startsWith(r))) continue;
  if (path.endsWith('.provenance.json')) continue;
  if (basename(path) === '.gitkeep') continue;
  const sidecar = path + '.provenance.json'; // e.g. foo.png.provenance.json
  // OR <basename-without-ext>.provenance.json — pick one convention; document in CONTEXT for Phase 2.
  try {
    const raw = await readFile(sidecar, 'utf8');
    const parsed = ProvenanceSchema.safeParse(JSON.parse(raw));
    if (!parsed.success) errors.push(`${path}: ${parsed.error.message}`);
  } catch (e) {
    errors.push(`${path}: missing or unreadable provenance sidecar (${sidecar})`);
  }
}

if (errors.length) {
  console.error('[provenance] validation failed:');
  errors.forEach((e) => console.error('  ' + e));
  process.exit(1);
}
console.log('[provenance] all assets carry valid provenance.');

Wire it up:

// package.json
"scripts": {
  "validate:assets": "node scripts/validate-assets.mjs",
  "lint": "eslint .",
  "test": "vitest run",
  "ci": "npm run lint && npm run test && npm run validate:assets && npm run build"
}

Sidecar naming convention decision: Use <filename>.provenance.json (e.g. garden-soil-01.png.provenance.json) — this keeps the sidecar adjacent in directory listings, makes it grep-friendly, and avoids ambiguity for files with the same stem but different extensions.

Refused-sample test: Commit assets/__samples__/refused/no-provenance.png (an actual image file, no sidecar). Then write a Vitest test that temporarily moves the refused asset out of __samples__/refused/ and asserts the validator script exits non-zero — proving the gate works. Restore the file in the test teardown.

Should this also run in Vite dev? No. Per CONTEXT D-03, the gate is CI-only. Running it in dev would slow down npm run dev startup; provenance churn during development is normal and shouldn't block iteration.

Anti-Patterns to Avoid

  • Adding a curator workflow doc / pre-commit hook / two-stage promotion directory — explicitly rejected by CONTEXT D-03. Sidecar + CI walker is the entire pipeline.
  • Pre-allocating save schema slots for Roothold / currentSeason / storyFlags — explicitly rejected by CONTEXT D-04. v1 schema contains only what Phase 2 will write.
  • Compiling .ink files in Phase 1 — explicitly out of scope. The path exists; the runtime doesn't.
  • Lint rule on UX strings — explicitly rejected by CONTEXT D-07. Anti-FOMO is a review document, not a code rule.
  • A separate npm run build:content Node script — Vite-native is simpler. Refactor only if Phase 5+ asset volume forces it.
  • BigQty wrapper / Zustand store / tick scheduler / Howler bootstrap / AudioContext gesture gate — all explicitly Phase 2.
  • Running the asset validator in Vite dev — CI only. Don't burden the dev loop.
  • Hand-rolling CRC32 / a custom hashcrc-32 is dependency-free at 426k ops/sec. Don't NIH this.

Don't Hand-Roll

Problem Don't Build Use Instead Why
IndexedDB transactions indexedDB.open + manual onupgradeneeded + Promise wrappers idb (openDB, db.put, db.get) Idb is 1.19kB brotli'd. Hand-rolling promise wrappers around the request-event API loses ~200 lines, has subtle bugs around transaction lifecycle, and reinvents what's already standard.
Save compression DIY LZ77 / DIY Base64 wrappers lz-string (compressToUTF16, compressToBase64) The library is 30k downloads/week, battle-tested in incremental-game saves for ~10 years. UTF-16 storage avoidance + URL-safe variants are subtle.
Schema validation Hand-written if (typeof x.id !== 'string') checks zod The whole point of Zod is type-safe runtime validation. Doing it by hand on save envelopes / content / provenance is error-prone and tedious.
Frontmatter parsing Hand-rolled --- ... --- parser gray-matter Edge cases (escaped delimiters inside YAML strings, multi-document YAML) bite.
YAML parsing Hand-rolled / JSON.parse of a yaml-as-json variant yaml (eemeli/yaml) YAML 1.2 spec is gnarly. yaml is the reference implementation.
ESLint architectural boundaries Pre-commit grep / shell scripts eslint-plugin-boundaries IDE integration, ESLint flat-config compatibility, element-type abstraction.
CRC32 Hand-rolled bit-twiddling crc-32 (SheetJS) Polynomial tables and seed handling are easy to get subtly wrong; CRC-32 package is dependency-free and tiny.
Glob walking the filesystem glob / fast-glob / DIY node:fs/promises readdir({withFileTypes: true, recursive: true}) for the asset validator; import.meta.glob for content Node 20+ supports recursive readdir natively. No new dependency needed for the 30-line validator.
Test runner Hand-rolled assertion library vitest Vite-native, parallel by default, ESM-first.
E2E driver Puppeteer / DIY @playwright/test Phase 1 install only — first spec lands in Phase 2.

Key insight: Phase 1 is infrastructure plumbing. The temptation is to over-engineer abstractions because there's no feature work to ground them. Resist. Every recommendation above is the thinnest shape that satisfies the success criterion. The user pushed back on overengineering before context was even gathered — the planner will too.

Common Pitfalls

Pitfall 1: import.meta.glob requires literal patterns

What goes wrong: Calling import.meta.glob(somePathVariable) silently fails. The glob string MUST be a string literal at the call site. Why it happens: Vite's plugin walks the AST at build time and can't resolve runtime expressions. How to avoid: Always inline the glob as a literal. If multiple patterns are needed, write multiple glob calls and merge the results. Warning signs: A "Could only use literals" error at build time.

[CITED: vite docs https://vite.dev/guide/features#glob-import]

Pitfall 2: navigator.storage.persist() returns false on iOS Safari most of the time

What goes wrong: First-save attempt to call persist() returns false on Safari/iOS unless the app has been added to home screen as a PWA or has been heavily interacted with. Player loses 30 days of save progress on Safari's periodic eviction (WebKit bug 266559). Why it happens: Safari's persistence-grant heuristic is opaque and conservative. Apple has explicitly documented "iOS provides no persistent storage guarantee" outside PWA context. How to avoid:

  • Always call navigator.storage.persist() on first save and check the result.
  • If false, write a sentinel flag (saveIsBestEffort: true) into the save envelope.
  • Surface a UX message at next opportunity (Phase 2+ writes the actual UI): "Your browser is keeping this save on a best-effort basis. Export a backup any time from Settings."
  • The Base64 export/import (CORE-09) is the resilience guarantee against this — make sure it lands in Phase 1 even though no UI button surfaces it yet. Warning signs: Telemetry showing save-loaded count < expected on Safari (Phase 8 polish, not Phase 1 concern — but the affordance must exist).

[CITED: MDN https://developer.mozilla.org/en-US/docs/Web/API/StorageManager/persist; PITFALLS.md #8]

Pitfall 3: JSON key ordering breaks checksums across runs

What goes wrong: JSON.stringify({a: 1, b: 2}) and JSON.stringify({b: 2, a: 1}) produce the same string in V8 today, but this is not spec-guaranteed across engines (and Object.fromEntries iteration order is technically insertion order). If a Phase 4 migration somehow re-creates an object with different key order, the checksum changes even though the data is identical. Why it happens: Default JSON.stringify doesn't canonicalize. How to avoid: Implement canonicalJSON() that sorts object keys recursively before stringifying, as shown in Pattern 1. Use it everywhere checksums are computed. Warning signs: A migration test fails with "expected X, got Y" where X and Y are visually identical JSON.

Pitfall 4: Vite ESM build of Phaser 4 silently breaks if peer versions misalign

What goes wrong: Phaser 4.0.0 had ESM build issues that 4.1.0 fixed. Mixing the wrong React version with the official template can produce runtime "cannot read property X of undefined" with no clear stack trace. Why it happens: The Phaser 4.1.0 release notes explicitly call out "important fixes for the ESM module build." How to avoid: Use the template-installed versions exactly. Don't manually upgrade Phaser without checking the release notes; pin Phaser to ^4.1.0. Warning signs: Game scaffold loads white screen with console errors after a dependency update.

[CITED: Phaser v4.1.0 "Salusa" release notes — https://phaser.io/news/2026/04/phaser-4-1-0-salusa-release]

Pitfall 5: Synchronous lz-string compression of huge saves blocks the main thread

What goes wrong: LZString.compressToBase64 is synchronous; on a 5MB save (Season 5+ scope) it can block the main thread for ~50ms+, dropping a frame mid-cello-ambient and ruining the watercolor mood. Why it happens: lz-string predates async APIs. How to avoid: Phase 1's saves are tiny (<10KB) so this isn't a real concern yet. Document the eventual mitigation: move save serialization into a Web Worker if Season 5+ saves grow past ~1MB. Don't build the worker now (premature) but do wrap save writes in src/save/codec.ts so the swap is one file later. Warning signs: Frame drops on save in a Season-5 simulation profile (Phase 8 concern).

[CITED: PITFALLS.md #8 perf table]

Pitfall 6: ESLint flat-config plugin imports break with mixed CJS/ESM template baseline

What goes wrong: The Phaser template ships an .eslintrc.cjs baseline (legacy config). Adding eslint-plugin-boundaries via the new flat-config eslint.config.js alongside the legacy file confuses ESLint 9+. Why it happens: ESLint 9 deprecated legacy config; templates may still ship the older shape during transition. How to avoid: Migrate the template's .eslintrc.cjs to a single eslint.config.js (flat) at the start of Phase 1. Don't run both. The migration is mechanical (@eslint/migrate-config automates it). Warning signs: ESLint runs but boundaries rule never fires; or runs twice with conflicting messages.

Pitfall 7: Synthetic v0→v1 migration test that doesn't actually exercise the registry

What goes wrong: It's tempting to write a "round-trip" test that wraps and unwraps the same object without ever calling migrate(). That tests envelope integrity but doesn't validate the migration framework. Why it happens: Convenience. How to avoid: The required Vitest assertions for CORE-07 are:

  1. migrate({garden: []}, 0) returns {payload: {...v1 shape...}, toVersion: 1}.
  2. Calling migrate(payload, 1) is a no-op.
  3. Calling migrate(payload, -1) or migrate(payload, 99) throws.
  4. The full round-trip — write v0 envelope to IDB, load it, migrate, validate via Zod — passes.
  5. Snapshot retention test: write 5 successive saves, list snapshots, assert exactly 3 remain in newest-first order. Warning signs: Test coverage is high but no test actually invokes migrations[1].

Code Examples

Save round-trip (Phase 1's load-bearing test)

// src/save/round-trip.test.ts
import { describe, it, expect } from 'vitest';
import { wrap, unwrap } from './envelope';
import { migrate, CURRENT_SCHEMA_VERSION } from './migrations';
import LZString from 'lz-string';

describe('save round-trip', () => {
  it('synthetic v0 envelope migrates, round-trips through Base64, and validates', () => {
    // Pretend a player had an old v0 save lying around.
    const v0Payload = { garden: [{ id: 'tile-1' }, { id: 'tile-2' }] };
    const v0Envelope = { schemaVersion: 0, payload: v0Payload, checksum: 'deadbeef' /* dummy */ };

    // Export
    const exported = LZString.compressToBase64(JSON.stringify(v0Envelope));
    expect(exported.length).toBeGreaterThan(0);

    // Import (in a fresh "browser")
    const imported = JSON.parse(LZString.decompressFromBase64(exported)!);
    expect(imported.schemaVersion).toBe(0);

    // Migrate
    const { payload, toVersion } = migrate(imported.payload, imported.schemaVersion);
    expect(toVersion).toBe(CURRENT_SCHEMA_VERSION);
    expect(payload).toMatchObject({
      garden: { tiles: expect.any(Array) },
      lastTickAt: expect.any(Number),
    });

    // Re-wrap with current version + valid checksum
    const v1Envelope = wrap(payload, toVersion);
    expect(unwrap(v1Envelope)).toEqual(payload);
  });
});

Persist API call with respectful surfacing

// src/save/persist.ts
export interface PersistResult {
  granted: boolean;
  apiAvailable: boolean;
}

export async function requestPersistence(): Promise<PersistResult> {
  if (!('storage' in navigator) || !('persist' in navigator.storage)) {
    return { granted: false, apiAvailable: false };
  }
  try {
    const granted = await navigator.storage.persist();
    return { granted, apiAvailable: true };
  } catch {
    return { granted: false, apiAvailable: true };
  }
}

// Caller (Phase 2 wires UI; Phase 1 ships function only):
//   const result = await requestPersistence();
//   if (!result.granted) {
//     // store flag in save envelope; surface in Phase 2 settings UI:
//     // "Your browser is keeping this save on a best-effort basis.
//     //  Export a backup any time from Settings."
//   }

Provenance sidecar example

// assets/north-stars/garden-soil-01.png.provenance.json
{
  "model_id": "claude-3.7-sonnet@2025-12 / sd-xl-watercolor-lora-v3",
  "checkpoint_hash": "sha256:abcd1234...",
  "prompt": "A walled cottage garden at dusk in late autumn, watercolor wash, real but slightly wrong wildflowers, golden palette, no fantasy elements",
  "seed": 0,
  "sampler": "DPM++ 2M Karras",
  "params": {
    "steps": 30,
    "cfg_scale": 7.5,
    "width": 1024,
    "height": 1024,
    "lora_weight": 0.8,
    "notes": "Phase 1 north-star reference image #01 of 20. Hand-curated; reroll-allowed via stored seed."
  }
}

Doctrine doc outline — anti-fomo-doctrine.md

Recommended structure (principle-level, ~1 page):

# Anti-FOMO Doctrine

This document is referenced at every UX, monetization, and copy review. It enumerates
mechanics this game does not use, with the reason for each, so the answer to a
"should we add X?" question is in writing rather than relitigated.

## Banned Mechanics

| Mechanic | Why Banned |
|----------|------------|
| Daily login bonuses | Presence is not a debt the game collects. |
| Login streaks | Skipping a day is allowed, even encouraged. |
| Limited-time content (events that disappear) | The game's premise is *what persists*. |
| Loss-aversion copy ("you'll lose your X") | Tonally incompatible with cozy/contemplative. |
| Re-engagement push notifications | Memory Storms (opt-in) are the *only* allowed notification class. |
| Rewarded ads | Anti-cozy; tonally incoherent. |
| Visible countdown timers in core UI | The cello is the timer. |
| "Don't miss out" / "limited time" / "only X hours left" copy | Bannable phrases. |

## Allowed Engagement

- Memory Storm opt-in notifications.
- "While you were away" letter on return (in Lura's voice, not a stat dump).
- Tab-title bloom indicator (UX-09, Phase 8).

## Review Checklist

When reviewing any UX/copy/monetization change, ask:
1. Does this create urgency around presence rather than around content?
2. Does this frame absence as loss?
3. Would removing this from the game make it less *cozy*?

If yes to any → reject or rewrite.

## Source Documents

- PROJECT.md "Out of Scope" — anti-features
- REQUIREMENTS.md UX-13, "Out of Scope" table (rows: gacha, daily login, streaks,
  limited-time, energy/stamina, rewarded ads, push spam)
- CLAUDE.md "Hard Thematic Constraints"
- .planning/research/PITFALLS.md #9

Doctrine doc outline — season-7-end-state.md

Recommended structure (principle-level, ~1 page):

# Season 7 End-State Design (Principle-Level)

This document answers the question that ends ROADMAP.md Phase 7's success criterion #4:
*"the finite Roothold ceiling from Phase 4 has held the line, and the game has ended
the way A Dark Room and Universal Paperclips ended."*

## What does *rest state* mean?

The rest state is the post-credits configuration the player can return to indefinitely
without grinding. Concretely:

- No new fragments are added to the pool. All authored content has been delivered.
- No new currency tiers unlock.
- The garden continues to render and respond to clicks. Plants can still be planted
  and harvested, but harvests yield re-readable previously-collected fragments —
  nothing new.
- The Pale has receded. The Heartsoil expands beyond the garden walls. Lura's
  arc has resolved. The Archivist's question has been answered (in the player's
  binary choice from Season 7's final scene).
- The cello and ambience continue. The world is *quiet*, *finite*, *understood*.

This is not "endgame content." It is **rest**.

## What is the finite Roothold ceiling tied to?

Roothold's ceiling is anchored in the **count of authored fragments and the count
of Seasons**, not in an arbitrary number. The ceiling is: *one cannot accumulate
more Roothold than the player has actually understood, and what the player can
understand is bounded by what the writer has actually written.*

Concrete tie:
- Roothold gain per Season is gated to a hard cap proportional to the fragment count
  of that Season + a small contribution from Roothold-relevant story beats.
- Total Roothold cap = Σ(per-Season caps).
- Phase 4 enforces this cap when it implements `migrate_v1_to_v2` and the prestige
  state machine. Phase 7 verifies the ceiling holds through full play.

## What tonal register does the coda live in?

- **Warm**, not pyrrhic. The garden persists *because* you tended it; this is
  earned redemption, not survival.
- **Quiet**, not climactic. The cello does not crescendo. It rests.
- **Specific**, not abstract. The final visible state is a *real* garden — the
  one this player built — viewed in soft dawn-silver light.
- **Final**, not infinite. There is no "Season 8." There is no New Game+. The
  Pale receded *here*, in *this* garden.

## What this document is NOT

- The text of the Season 7 binary-choice scene (authored Phase 7).
- The text of either ending paragraph (authored Phase 7).
- Lura's final line (authored Phase 7).
- The credits/coda screen visual treatment (designed Phase 7).
- The tonal register or shape of the final fragments themselves.

This document is *the principle the economy obeys, the writer obeys, and the
Phase 7 designer obeys* — not the implementation of any of those.

## Source Documents

- PROJECT.md core value — "what survives is what you understood"
- REQUIREMENTS.md SEAS-04, SEAS-09, SEAS-10, STRY-08
- ROADMAP.md Phase 7
- .planning/research/PITFALLS.md #1

Runtime State Inventory

(Greenfield project — no prior runtime state to migrate.)

Category Items Found Action Required
Stored data None — first commit; no databases, no IndexedDB writes, no on-disk state None
Live service config None None
OS-registered state None None
Secrets/env vars None — no .env files, no API keys yet None
Build artifacts None — no node_modules/, dist/, or compiled output yet First npm install will create node_modules/; .gitignore from template covers it

Nothing to migrate — Phase 1 is the first phase that produces any artifacts.

Common Pitfalls (CI / Tooling Specific)

(See "Common Pitfalls" section above for the full list of seven pitfalls. This section additionally documents three CI-tooling traps unique to first-phase infrastructure work.)

CI Pitfall A: GitHub Actions caching node_modules instead of ~/.npm

What goes wrong: Cache restores node_modules/ directly; subsequent npm install doesn't refresh transitive deps and silently runs against stale versions. How to avoid: Use actions/setup-node@v4 with cache: 'npm' (caches ~/.npm based on package-lock.json) — never cache node_modules/ directly.

CI Pitfall B: Vitest in CI doesn't fail the build on uncaught test errors

What goes wrong: vitest run exits 0 if no tests exist (Phase 1 ships only 2-3 test files); CI passes a "no tests" run as success. How to avoid: Add --passWithNoTests=false to the vitest CLI call in CI so empty runs are flagged.

CI Pitfall C: ESLint flat-config + --max-warnings 0 in template

What goes wrong: Template may default to allowing warnings; the boundaries rule fires as a warning instead of an error. How to avoid: Set boundaries/element-types severity to error (numeric 2 or string 'error') explicitly. Add npm run lint -- --max-warnings 0 to the CI script.

State of the Art

Old Approach Current Approach When Changed Impact
Phaser 3 + React via webpack Phaser 4 official template (Vite + React + TS) April 2026 (Phaser 4 GA) The official template is the path. Don't pick Phaser 3 in 2026.
ESLint legacy config (.eslintrc.*) ESLint flat config (eslint.config.js) ESLint 9 (2024) Migrate the template's legacy file at start of Phase 1.
Zod 3.x Zod 4.x Zod 4 GA late 2025 Use 4.x for new code; STACK.md's "3.23+" is conservative; 4.4.3 is current.
Vite 6 (current LTS) Vite 8 (Rolldown) 2026 Phaser 4 template may install Vite 6 or 7. Don't aggressively upgrade — accept template version.
js-yaml yaml (eemeli/yaml) 2023+ YAML 1.2 spec compliance; better TypeScript types.
Vitest workspace config Vitest projects config Vitest 3.2+ (2025) Workspace is deprecated. Phase 1 doesn't need projects; single config is fine.

Deprecated/outdated:

  • ESLint legacy config — migrate at scaffold time.
  • js-yaml — use yaml for new code.
  • Phaser 3 starter templates — use Phaser 4 official template.

Assumptions Log

Every claim in this document is either [VERIFIED] against a tool/registry, [CITED] from a linked source, or [ASSUMED]. Below are the [ASSUMED] claims that the planner / discuss-phase may want to validate with the user before locking.

# Claim Section Risk if Wrong
A1 The Phaser 4 official template (npm create @phaserjs/game@latest) installs Vite 6 or 7 (not yet 8). Standard Stack Low — the planner can verify by running the scaffold once. If Vite 8, no behavior change needed.
A2 Sidecar naming convention <filename>.<ext>.provenance.json (vs. <filename>.provenance.json) is right for Phase 1. Pattern 6 Low — convention can be flipped in 5 minutes if user has preference. Document choice in CONTEXT for Phase 5.
A3 JSON.stringify with sorted keys is canonical-enough for CRC32 (vs. a stricter canonical-JSON library like json-stable-stringify). Pattern 1 Low — adding json-stable-stringify is a one-line change if a Phase 4 migration ever produces non-stable output.
A4 The synthetic v0 schema {garden: []} is a sufficiently simple prior for the demo migration. Pattern 2 Very low — CONTEXT D-05 explicitly endorses any synthetic v0 shape.
A5 The 1020 north-star images can be produced as part of Phase 1 execution without a vendor lock-in (CONTEXT D-02). Standard Stack Medium — depends on whether the user has access to some AI image generation tool during Phase 1 execution. If not, a placeholder commit (10 hand-painted PNGs from a free reference + sidecars filled in honestly) satisfies AEST-08/09 minimally.
A6 The inkjs + inklecate install in Phase 1 (with no compilation actually happening) is a "cheap path forward" the user prefers over deferring the install entirely. Standard Stack Very low — installing two deps that aren't used is harmless; deferring them leaves a Phase 2 task. Either is fine.
A7 Phase 1's Vitest config can be a 5-line minimal vitest.config.ts with Node environment (not jsdom) — since save tests don't touch DOM. Validation Architecture Very low — jsdom is needed for Phase 2 React-component tests, not Phase 1.

If this list is non-empty after planning, the planner should surface these for user confirmation before locking the plan. None of them block research.

Open Questions

  1. Should the canonical-JSON implementation be json-stable-stringify or hand-rolled?

    • What we know: JSON.stringify is non-canonical by spec; sorted-key recursion is the standard fix.
    • What's unclear: Does Phase 4+ need stronger canonicalization (Unicode normalization, number formatting)?
    • Recommendation: Hand-roll the sorted-key recursion in Phase 1 (5 lines). Add json-stable-stringify as a dependency only if Phase 4+ surfaces a concrete reason.
  2. Does the asset validator need a .provenance.json schema versioning field for Phase 5 vendor migration?

    • What we know: CONTEXT D-02 defers vendor pinning to Phase 5.
    • What's unclear: When Phase 5 picks a vendor, will the schema need to change (e.g., add pipeline_version, human_curated_at)?
    • Recommendation: Add an optional provenance_schema_version: number field now (default 1); Phase 5 bumps it if the schema evolves. This is one optional field — cheap insurance.
  3. Should src/sim/, src/render/, src/ui/, src/audio/, src/store/ be created with .gitkeep or with stub index.ts files?

    • What we know: The directories must exist for ESLint boundaries patterns to match.
    • What's unclear: .gitkeep is convention; index.ts would let ESLint also lint imports through them.
    • Recommendation: .gitkeep for now (simpler). Phase 2 replaces with real entry points.
  4. Does Phase 1 need a CI workflow file at all (e.g., .github/workflows/ci.yml), or just package.json scripts?

    • What we know: Solo dev, the user has rejected ceremonial workflows.
    • What's unclear: Is GitHub Actions in scope for Phase 1, or is "CI" satisfied by npm run ci running locally / in the dev's preferred CI?
    • Recommendation: Ship a 20-line .github/workflows/ci.yml that runs npm run ci on PR + push to main. This is minimum-viable CI, not ceremony. If user prefers no GitHub Actions, swap to a simpler CI of their choice; the ci script is platform-agnostic.

Environment Availability

Dependency Required By Available Version Fallback
Node.js (≥20 for recursive: true readdir, ≥22 ideal for crypto.hash native) All build steps 24.14.1
npm All install steps 11.11.0 yarn / pnpm acceptable but template defaults to npm
git Source control + commit_docs 2.51.0
Modern browser (Chrome/Firefox/Safari/Edge last 2) for IndexedDB testing CORE-04 verification Assumed ✓ (dev machine) Use Vitest's happy-dom for headless IndexedDB if no real browser available, but Playwright will need real browsers for Phase 2 PIPE-07
Image generation tool (any) for north-star reference set AEST-08 / AEST-09 / D-01 Unknown — depends on user If no tool available: commit 10 hand-painted reference images (or licensed-CC-BY photographs of real cottage gardens) with provenance fields filled in honestly (model_id: "human", prompt: "n/a", etc.). Schema accepts arbitrary model_id strings.

Missing dependencies with no fallback: None known. All Phase 1 work runs on Node + npm + a browser.

Missing dependencies with fallback: Image generation tool — see above. Provenance schema is permissive enough that "honest human-painted" entries are valid.

Validation Architecture

Test Framework

Property Value
Framework Vitest 4.1.5 (verified 2026-05-08)
Config file vitest.config.ts (Wave 0 deliverable; minimal — no jsdom in Phase 1)
Quick run command npm test (alias for vitest run)
Full suite command npm run ci (lint + test + validate-assets + build)

Phase Requirements → Test Map

Req ID Behavior Test Type Automated Command File Exists?
CORE-04 IndexedDB save round-trips with localStorage fallback path unit npx vitest run src/save/db.test.ts Wave 0
CORE-05 requestPersistence() returns {granted, apiAvailable} shape and handles missing API unit npx vitest run src/save/persist.test.ts Wave 0
CORE-06 wrap() produces valid envelope; unwrap() rejects checksum mismatch unit npx vitest run src/save/envelope.test.ts Wave 0
CORE-07 migrate({garden:[]}, 0) produces v1 shape; migrations[1] is invoked exactly once unit npx vitest run src/save/migrations.test.ts Wave 0
CORE-08 After 5 successive snapshot() calls, exactly 3 newest entries remain in save_snapshots store unit npx vitest run src/save/snapshots.test.ts Wave 0
CORE-09 Base64 export → import → migrate → unwrap yields original payload unit (round-trip) npx vitest run src/save/round-trip.test.ts Wave 0
CORE-10 src/sim/ importing from src/render/ produces ESLint error static-analysis (CI) npm run lint (with a fixture file in src/sim/__test_violation__/ that should error, then asserted via a Vitest snapshot of eslint --rulesdir output) Wave 0
CORE-01 Game scaffold npm run build produces a valid bundle smoke (manual + Phase 2 Playwright) npm run build (manual verification in Phase 1; Phase 2 PIPE-07 adds Playwright load-time spec) manual-only in Phase 1
PIPE-01 Demo content file with deliberate schema violation fails the build unit (build-time) npx vitest run src/content/loader.test.ts (mocks the glob) + manual npm run build with bad fixture Wave 0
PIPE-03 Asset validator script exits non-zero on a fixture missing provenance integration node scripts/validate-assets.mjs (fixture-driven Vitest test that temporarily creates assets/__test__/no-provenance.png, asserts script exits 1, cleans up) Wave 0
PIPE-05 .planning/anti-fomo-doctrine.md and .planning/season-7-end-state.md exist with required H2 sections doc lint (Vitest filesystem assertion) npx vitest run scripts/doctrine.test.ts Wave 0
PIPE-06 All save migration tests run on every CI build meta — verified by CI script npm run ci includes npm test Wave 0
AEST-08 / AEST-09 All assets in assets/ (excluding __samples__/refused/) have valid provenance sidecars integration node scripts/validate-assets.mjs (covered by PIPE-03 test) Wave 0
STRY-09 (No automated test in Phase 1 — establishes /content/ convention only; Phase 2+ enforces by code review since CONTEXT D-07 rejects lint rules on UX strings) manual-only n/a
UX-13 (No automated test — doctrine doc is enforced by review per CONTEXT D-07) manual-only n/a

Sampling Rate

  • Per task commit: npm test (vitest run, ~5 seconds for the entire Phase 1 test set).
  • Per wave merge: npm run ci (lint + test + validate-assets + build, ~30 seconds).
  • Phase gate: npm run ci green, plus manual smoke that npm run dev opens the Phaser scaffold in a browser. gsd-verify-work consumes the green CI run as evidence.

Wave 0 Gaps

All test infrastructure must be created in Wave 0 (no test files exist in greenfield repo):

  • vitest.config.ts — minimal config (test environment: node)
  • playwright.config.ts — installed only, no specs in Phase 1
  • src/save/db.test.ts — covers CORE-04 (idb open + put + get; happy-dom for IndexedDB shim)
  • src/save/persist.test.ts — covers CORE-05
  • src/save/envelope.test.ts — covers CORE-06
  • src/save/migrations.test.ts — covers CORE-07
  • src/save/snapshots.test.ts — covers CORE-08
  • src/save/round-trip.test.ts — covers CORE-09 (and integrates CORE-04, 06, 07)
  • src/save/checksum.test.ts — covers checksum determinism (canonical JSON keys)
  • src/content/loader.test.ts — covers PIPE-01 (mocked import.meta.glob)
  • scripts/doctrine.test.ts — covers PIPE-05 (asserts both doctrine docs exist with required H2 headings)
  • scripts/validate-assets.test.ts — covers PIPE-03 / AEST-08 / AEST-09
  • eslint.config.js — ESLint flat config with eslint-plugin-boundaries + boundary rule for src/sim/src/render//src/ui/
  • src/sim/__test_violation__/violator.ts (test fixture) — deliberate boundary violation that the lint rule MUST flag; lint test asserts ESLint output contains the expected error
  • .github/workflows/ci.yml (or equivalent) — runs npm run ci on PR + push
  • Framework install: npm install -D vitest@^4 @vitest/ui happy-domhappy-dom is needed for IndexedDB in node-side Vitest; lighter than jsdom

Security Domain

security_enforcement is not explicitly set in .planning/config.json; treat as enabled.

Applicable ASVS Categories

ASVS Category Applies Standard Control
V2 Authentication no No auth in Phase 1 (or v1 — local-first save model). Cloud sync is v2.
V3 Session Management no No sessions in Phase 1.
V4 Access Control no Single-player; no multi-tenant data.
V5 Input Validation yes Save envelope validated by Zod on every load (rejects malformed Base64 import, malformed envelope shape). Provenance sidecar validated by Zod. Content files validated by Zod at build.
V6 Cryptography partial CRC32 is not a cryptographic hash; it's a corruption-detection checksum. Saves are not authenticated — a player editing their save is fine (single-player, no leaderboards, no monetization gates in Phase 1). Document explicitly: this is by design, not by oversight. PITFALLS.md "Security/Integrity Mistakes" already endorses this.
V7 Error Handling yes Save load failures must surface the recovery option (CORE-06) — not silent reset. Phase 1 ships the error class + handler shape; Phase 2 wires the UI.
V8 Data Protection partial Saves contain no PII in Phase 1 (just garden state); even player-chosen names are not collected (no Keeper name per STRY-07).
V14 Configuration yes npm audit should be clean; pinned versions; no environment-secret leak in repo.

Known Threat Patterns for Phaser 4 + React + IndexedDB + lz-string stack

Pattern STRIDE Standard Mitigation
Save tampering (player edits Base64 export) Tampering Acceptable — single-player; if Phase 2+ economy values become interesting to tamper, document explicitly that we don't defend against client-side editing. CRC32 detects corruption (lossy storage, transmission errors), not adversarial tampering.
Malformed Base64 import (DoS via huge inflated string) DoS lz-string.decompressFromBase64 has bounded output for bounded input; additionally, validate payload size before decompressing (cap at e.g. 50MB).
Cross-origin import from URL params (open future risk if save-via-link added) Tampering / Spoofing Phase 1 has no URL-import; flag for Phase 4+ if added: import flow must require explicit user confirmation, never auto-load.
npm install supply-chain (e.g., a transitive dep gets compromised) Tampering package-lock.json committed; npm audit in CI; pin Phase 1 deps to caret ranges (caught by lockfile).
Provenance sidecar spoofing (a malicious contributor adds an asset with a fabricated provenance) Spoofing Single-developer project in Phase 1; not a real threat. Phase 8+ if external contributors are added: provenance includes a human_reviewed_by field signed by the curator. Out of scope for Phase 1.
Path traversal via import.meta.glob (a malicious content file with ../../ in frontmatter) Tampering Vite glob expansion is at build time; no runtime user input. Not exploitable. Validation step never resolves paths from frontmatter values.

Phase 1 explicit security non-goals (documented for clarity)

  • Save authentication — by design. Player tampering with their own save is acceptable in a contemplative single-player game.
  • Cloud sync security — out of v1 scope.
  • Multi-user data isolation — single-player; no isolation needed.
  • Auth / session tokens — none in v1.

Project Constraints (from CLAUDE.md)

The following directives from CLAUDE.md constrain Phase 1 implementation. The planner must verify the plan honors each:

Directive Phase 1 Application
TypeScript strict; no any in production code tsconfig.json ships with "strict": true; save-layer code uses unknown + Zod parsing for untrusted inputs (env on load, Base64 import).
Player-visible strings externalized in /content/, never hardcoded Phase 1 establishes the convention by not violating it. No player-facing strings ship in Phase 1 source code (no UI yet).
Memory fragment IDs are stable strings (season3.canopy.lura_07.vignette), never numeric Zod schema in src/content/schemas/fragment.ts enforces with regex (^season\d+\.[a-z0-9._-]+$).
Simulation modules are pure — no Date.now(), no setInterval, no DOM, no fetch src/sim/ is empty in Phase 1 (Phase 2+ fills); ESLint boundary rule is the structural enforcement.
BigNumbers go through the typed BigQty wrapper around break_eternity.js Phase 2 deliverable, not Phase 1. Do not pre-create BigQty per CONTEXT.
Save format always carries {schemaVersion, payload, checksum}. Never serialize raw state Pattern 1 above.
New AI-generated assets must carry full provenance metadata + pass curation gate Pattern 6 above.
Tone — player-facing copy is warm, specific, intermittent, sometimes funny, sometimes devastating No player copy in Phase 1; doctrine docs themselves can be functional — they are project-internal.
/content/ is at repo root, alongside src/, assets/, .planning/ CONTEXT D-11 confirms; structure above honors it.
/assets/ at repo root CONTEXT D-12 confirms.
No monorepo / no workspaces CONTEXT D-12 confirms; single package.json.

Sources

Primary (HIGH confidence)

Secondary (MEDIUM confidence)

Tertiary (LOW confidence — flagged for validation)

Metadata

Confidence breakdown:

  • Standard stack: HIGH — every package version verified against npm registry on 2026-05-08; locked versions match user constraints from CONTEXT.
  • Architecture (save layer, content pipeline, ESLint, validator): HIGH — patterns verified against official docs (idb, Vite, eslint-plugin-boundaries, MDN); shape is conservative and minimum-viable per user's anti-overengineering directive.
  • Pitfalls: HIGH for stack-specific, MEDIUM for browser quirks (Safari persist behavior is fast-moving; verified against MDN today but worth re-confirming if Phase 1 execution drags past 60 days).
  • Doctrine doc outlines: MEDIUM — they are suggestions for principle-level structure; the user is the final arbiter of what "principle-level" means for The Last Garden. The shape is informed by the source documents the docs consolidate.
  • Validation architecture: HIGH — every Phase 1 success criterion maps to a concrete automated test command, with one exception (CORE-01 load-time, which is manual in Phase 1 and gets Playwright coverage in Phase 2 PIPE-07).

Research date: 2026-05-08 Valid until: 2026-06-08 (30 days — stack is stable but Vite 8 / Phaser 4.x landings could rotate)