80 KiB
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 10–20 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_idandcheckpoint_hashfields 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.jsonper 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 realmigrate_v1_to_v2when 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). Restructuresrc/to exposesrc/sim/,src/render/,src/ui/, plus supportingsrc/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-boundariesvsno-restricted-paths). - Checksum algorithm (CRC32, simple hash, etc.).
- Migration registry shape.
idbwrapper API surface insidesrc/save/.- Vitest / Playwright config files;
vite.config.tsboundary plugin choices. - Where the Zod content schemas live and how the build step is invoked (Vite plugin vs separate
npm run build:contentscript). - Specific images for the 10–20 north-star generations.
Deferred Ideas (OUT OF SCOPE)
- AI vendor lock-in / model pinning (Phase 5).
BigQtywrapper 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 10–20-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 | 10–20 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) │
└──────────────────────────┘
Recommended Project Structure
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 — 10–20 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 2–7 andeager: truefor 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:
npm install inkjs inklecateso the deps are locked.- A
package.jsonscript"compile:ink": "inklecate -o src/content/compiled-ink/ content/dialogue/*.ink || true"that exists but operates on an empty directory (no-op). /content/dialogue/.gitkeepso the directory is real.- No Ink validation in Phase 1 — Phase 2 adds it when the first
.inkfile 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
.inkfiles 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:contentNode 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 hash —
crc-32is 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:
migrate({garden: []}, 0)returns{payload: {...v1 shape...}, toVersion: 1}.- Calling
migrate(payload, 1)is a no-op. - Calling
migrate(payload, -1)ormigrate(payload, 99)throws. - The full round-trip — write v0 envelope to IDB, load it, migrate, validate via Zod — passes.
- 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— useyamlfor 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 10–20 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
-
Should the canonical-JSON implementation be
json-stable-stringifyor hand-rolled?- What we know:
JSON.stringifyis 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-stringifyas a dependency only if Phase 4+ surfaces a concrete reason.
- What we know:
-
Does the asset validator need a
.provenance.jsonschema 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: numberfield now (default1); Phase 5 bumps it if the schema evolves. This is one optional field — cheap insurance.
-
Should
src/sim/,src/render/,src/ui/,src/audio/,src/store/be created with.gitkeepor with stubindex.tsfiles?- What we know: The directories must exist for ESLint boundaries patterns to match.
- What's unclear:
.gitkeepis convention;index.tswould let ESLint also lint imports through them. - Recommendation:
.gitkeepfor now (simpler). Phase 2 replaces with real entry points.
-
Does Phase 1 need a CI workflow file at all (e.g.,
.github/workflows/ci.yml), or justpackage.jsonscripts?- 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 cirunning locally / in the dev's preferred CI? - Recommendation: Ship a 20-line
.github/workflows/ci.ymlthat runsnpm run cion 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; theciscript 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 cigreen, plus manual smoke thatnpm run devopens the Phaser scaffold in a browser.gsd-verify-workconsumes 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 1src/save/db.test.ts— covers CORE-04 (idb open + put + get; happy-dom for IndexedDB shim)src/save/persist.test.ts— covers CORE-05src/save/envelope.test.ts— covers CORE-06src/save/migrations.test.ts— covers CORE-07src/save/snapshots.test.ts— covers CORE-08src/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 (mockedimport.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-09eslint.config.js— ESLint flat config witheslint-plugin-boundaries+ boundary rule forsrc/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) — runsnpm run cion PR + push- Framework install:
npm install -D vitest@^4 @vitest/ui happy-dom—happy-domis 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)
- eslint-plugin-boundaries README on GitHub — config patterns, element-types rule, flat-config example
- npm registry — verified 2026-05-08 for all package versions —
npm view <package> versionfor phaser, react, vite, typescript, idb, lz-string, zod, vitest, @playwright/test, eslint-plugin-boundaries, crc-32, gray-matter, yaml, inkjs, inklecate, howler, break_eternity.js, zustand - idb GitHub — jakearchibald/idb — openDB API, upgrade callback, put/get shortcuts
- Phaser v4.1.0 "Salusa" release notes — version, ESM fixes, Layer rework
- Phaser official template — phaserjs/template-react-ts — root structure, src/ layout, package.json scripts, dependencies
- MDN —
navigator.storage.persist()— return value semantics,falseconditions - Vite docs — features (
import.meta.glob) — eager mode, query/import options, literal-pattern requirement - Zod docs — z.infer + safeParse — type derivation, runtime validation
- Vitest docs — projects (replaces workspace) — multi-config; not needed for Phase 1 single config
Secondary (MEDIUM confidence)
- SheetJS/js-crc32 — crc-32 npm package — performance benchmarks (~426k ops/sec), API
- Emanuele Feronato — Getting Started with Phaser 4 (April 2026) —
create-phaser-gameflow walkthrough - Trivikr — benchmark-crc32 — comparative speed of CRC32 npm packages
- pieroxy.net — lz-string —
compressToBase64/decompressFromBase64semantics, encoding rates
Tertiary (LOW confidence — flagged for validation)
- Magicbell — PWA iOS Limitations 2026 — Safari 17 partial persistence support; verified by MDN secondarily
- DEV.to — Why Your IndexedDB Data Keeps Disappearing — Chrome storage-pressure eviction patterns
- WebKit Bug 266559 — Safari periodic eviction of LocalStorage and IndexedDB — referenced in PITFALLS.md; not re-verified in this research session
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)