# Phase 2: Season 1 Vertical Slice (Soil) - Research **Researched:** 2026-05-09 **Domain:** Browser narrative idle game vertical slice (Phaser 4 canvas + React 19 DOM overlay + pure-sim core + Ink narrative + extended IndexedDB save) **Confidence:** HIGH on stack/architecture (entire stack already locked + scaffolded in Phase 1, code-surface verified file-by-file). HIGH on tick scheduler / BigQty / Zustand-bridge patterns (Phaser 4's React EventBus pattern is the documented integration; idle-game tick-scheduler shape is canonical per `.planning/research/ARCHITECTURE.md`). MEDIUM on Ink↔Zustand bridge specifics (inkjs API verified in installed module; integration shape is ours to design). MEDIUM on Playwright fast-forward strategy (multiple viable mechanisms; the choice is partly ergonomic). ## Summary Phase 2 is a vertical slice on top of an already-laid foundation. Phase 1 shipped all the retrofit-hostile infrastructure: the save layer (IndexedDB + LocalStorage + CRC-32 + lz-string + Base64 + migration chain), the Vite-native content pipeline (Markdown+frontmatter / YAML, Zod-validated), the ESLint firewall (sim cannot import render or ui — `eslint-plugin-boundaries` flat config in `eslint.config.js`), the Phaser 4 + React 19 + Vite + TypeScript scaffold (`src/game/main.ts`, `src/PhaserGame.tsx`, `src/App.tsx`), the Vitest + Playwright test rig, the asset provenance gate, the doctrine docs, and `npm run ci` as the gate. Phase 2 lands the three deferred "day-one of feature code" foundations in a single phase (`BigQty`, the Zustand 5 store, the tick scheduler), then builds the vertical slice on top: 4×4 garden, 2–3 plant types, click+inline seed picker, sprout→mature→ready growth, harvest yields exactly one fragment, full-screen Memory Journal, three Ink-authored Lura gate visits gated on 1st/4th/8th harvest, authored Ink letter-from-the-garden, save-management Settings, Playwright e2e proving the full loop end-to-end. The phase's load-bearing technical decisions are: (1) the **tick scheduler** is the only owner of wall-clock access — sim modules stay pure, time is injected, negative deltas refused, single offline catch-up clamped to 24h, with the Phaser update loop driving the scheduler from `src/game/scenes/`; (2) the **BigQty** wrapper around `break_eternity.js` lands in `src/sim/numbers/` and is the type all economic values flow through, even though Season 1's actual numbers never need it (per CLAUDE.md "from day one of feature code" + Pitfall 7); (3) the **Phaser↔React bridge** uses Phaser 4's official template `EventBus` pattern (a `Phaser.Events.EventEmitter` instance accessible to both worlds) plus a Zustand 5 vanilla `createStore` — the sim writes to the store via a slim adapter (sim cannot import `src/store/` directly, since the store imports from `src/ui/` selectors), and React reads from it via hooks; (4) the **save-schema extension** updates `V1Payload` in-place per CONTEXT D-34 — Phase 1's v1 has shipped no production saves, so adding fields is type-extension, not a `migrate_v1_to_v2`, which keeps `migrations[1]`'s synthetic v0→v1 demo green; (5) the **fragment selector** is a pure function in `src/sim/memory/` taking `(state, currentSeason, gardenContext, fragmentPool, seed) → fragmentId`, gating on Season + plant type + harvest count and tracking exhaustion via the existing `harvestedFragmentIds` array; (6) **Ink integration** uses `inklecate` (installed) compiled at build via an npm script that emits `*.ink.json` into `src/content/compiled-ink/`, with `inkjs` (installed, v2.4.0) loading the JSON at runtime; Lura's branches read game state by setting Ink variables from Zustand snapshots before `Continue()` is called. **Primary recommendation:** Plan Phase 2 as **5 vertical-slice plans** delivered in 3 waves: Wave 0 lands the three foundations (BigQty + Zustand store + tick scheduler) as a single non-feature plan that unblocks everything; Wave 1 lands two parallel vertical slices (the planting-and-growth slice + the journal-and-fragments slice); Wave 2 lands two more parallel slices (Lura gate visits + the offline-letter loop) and the Playwright e2e smoke. Each Wave-1+ plan must pass `npm run ci` standalone. ## Architectural Responsibility Map Each capability of the Phase 2 vertical slice is owned by a primary architectural tier, with the firewall enforced by `eslint.config.js`. Cross-tier communication goes through the Zustand store and the Phaser EventBus. | Capability | Primary Tier | Secondary Tier | Rationale | |------------|-------------|----------------|-----------| | Garden tile state (16 tiles, plant data, plantedAtTick) | `src/sim/garden/` | — | Pure data; sim mutates; rendering reads via store. | | Plant growth state machine (sprout → mature → ready) | `src/sim/garden/` | — | Deterministic from `(plantedAtTick, currentTick, plantType.duration)`. No DOM. | | Tick scheduler / monotonic clock | `src/sim/scheduler/` | `src/game/scenes/` (drives via Phaser update) | Only owner of wall-clock. Sim's `simulate(state, dtTicks)` is pure; scheduler injects time. | | Offline catch-up (24h cap, refuse negative dt) | `src/sim/scheduler/` | — | Pure function over saved state + delta. Fires `silent: true` to suppress UI side effects. | | BigQty wrapper around break_eternity.js | `src/sim/numbers/` | — | Pure math; serialization helpers; no DOM. | | Fragment selector (deterministic, gating, no-dup) | `src/sim/memory/` | — | Pure function over `(state, fragments, seed)`. | | Lura beat gating (1st/4th/8th harvest count) | `src/sim/narrative/` | — | Reads sim state's harvest count; enqueues `beatId` events. Does NOT load Ink content. | | Auto-harvest during offline (D-10) | `src/sim/garden/` | — | Same simulate loop, with auto-harvest branch when player is absent (state flag). | | Garden tile rendering (Phaser primitives) | `src/render/garden/` | — | Empty-tile outline + plant stage shape + ready-pulse via Phaser primitives. No DOM. | | Begin screen (gesture gate + AudioContext.resume) | `src/ui/begin/` | — | Full-screen React DOM modal; first-run only per D-22; tap calls `audioContext.resume()`. | | Memory Journal (full-screen modal) | `src/ui/journal/` | — | DOM per MEMR-05 (selectable / copy-pasteable); reveals after 1st harvest per D-23. | | Lura dialogue overlay | `src/ui/dialogue/` | `src/sim/narrative/` (gates), Ink runtime (renders) | DOM-rendered per D-15 (selectable text from day one). | | Letter-from-the-garden (full-screen modal) | `src/ui/letter/` | Ink runtime (templates) | DOM full-screen per D-20 (≥5min absence threshold). | | Settings (Export/Import/Restore) | `src/ui/settings/` | `src/save/` (already shipped) | DOM modal; corner icon + hotkey per D-29. | | Inline seed picker (popover over clicked tile) | `src/ui/garden/` | `src/render/garden/` (provides tile screen position) | DOM popover positioned over Phaser canvas via tile→screen coord conversion. | | Persistence-result toast (D-30) | `src/ui/toast/` | `src/save/persist.ts` (data) | One-time soft toast in voice on first save if denied. | | Save persistence (already shipped) | `src/save/` | — | Phase 1's complete layer. Phase 2 imports only via `src/save/index.ts`. | | Save lifecycle hooks (visibilitychange, beforeunload, Season transition) | `src/save/` (existing) + `src/PhaserGame.tsx` (subscribes) | — | Browser event listeners live with the React shell; serialize calls go through the save barrel. | | Content loader (already shipped) | `src/content/` | — | Phase 2 drops `/content/seasons/01-soil/*.{md,yaml}` and `/content/dialogue/season1/*.ink`. | | Ink runtime bridge | `src/sim/narrative/` (gates only) + `src/ui/dialogue/` (rendering) | — | inkjs `Story` instances live in UI tier; sim never imports inkjs. | | Phaser ↔ React EventBus | `src/PhaserGame.tsx` (mounts) + scenes + UI | Zustand store (alternate channel) | Per Phaser 4 official template. Used for one-shot scene-ready / scene-event signals; persistent state goes through Zustand. | **Architectural firewall reminder (CORE-10, ESLint-enforced):** `src/sim/` cannot import from `src/render/` or `src/ui/`. The store sits in `src/store/` and is allowed both directions (sim writes; UI reads). The Phaser scene (`src/game/`) reads from the store and writes commands; it does NOT import sim modules directly — it dispatches commands via the store, and the scheduler picks them up. ## User Constraints (from CONTEXT.md) ### Locked Decisions (Copied verbatim from `02-CONTEXT.md`. Numbering preserved.) **Garden Geometry & Input** - **D-01:** Garden is a **4×4 fixed grid (16 tiles)**. - **D-02:** Seed placement is **click-empty-tile → inline seed picker** (small popover anchored to the clicked tile, listing currently-unlocked plant types; single tap commits). No persistent seed sidebar. - **D-03:** **2–3 plant types** ship in Season 1. Each plant has its own growth duration and fragment pool. - **D-04:** **Infinite seed supply from start** — anti-FOMO. Meaningful constraint is *time*, not seed inventory. - **D-05:** **First plant available from start; remaining 1–2 unlock by fragment-count thresholds** harvested. - **D-06:** Empty tile look = **faint outlined tile + subtle hover state** (Phaser primitive draw). - **D-07:** Post-harvest tile **returns immediately to empty** with a brief acknowledgement beat. **Time Density (Growth + Offline)** - **D-08:** First-plant active-play growth = **~2–5 minutes** (sprout → ready). - **D-09:** **Per-plant durations vary** (short / medium / longer) within the ~2–5min band. - **D-10:** **Auto-harvest during offline; manual in active play.** Auto-harvested plants queue into the offline event log; the *letter* narrates them. - **D-11:** **24h offline cap is surfaced silently in the letter's voice** — no numeric "you were gone for 28 hours" copy. **Lura's Season 1 Arc** - **D-12:** Lura is present as **discrete visits at the gate** — not a persistent chat thread. - **D-13:** **3 beats in Season 1: arrival · mid · farewell.** - **D-14:** Beats gated by fragment-count thresholds — beat 1 after **1st** harvest; mid after **4th**; farewell after **8th**. Counts come from sim ticks, not wall time (STRY-10). - **D-15:** Beat-fire UX = **subtle gate indicator + player-initiated visit.** Conversation opens as a **React DOM dialogue overlay**. - **D-16:** All Lura dialogue is authored in **Ink (`.ink`) under `/content/dialogue/`**. **Letter-from-the-Garden (UX-02)** - **D-17:** Letter composed from **authored skeleton + templated insertions**. Hand-authored Ink passages with named variable slots; specifics flow in from the offline event log. - **D-18:** Letter authoring lives in **Ink (`.ink` files in `/content/dialogue/`)**. - **D-19:** Save schema gains a small **`offlineEvents`** block: per-plant counts of plants bloomed, list of auto-harvested fragment IDs, flag for any newly-unlocked Lura beat queued. Phase-2 schema *extension*, not a migration. - **D-20:** Letter triggers when **absence ≥ 5 minutes**. Below threshold, no letter. Full-screen DOM overlay; one tap dismisses. **Begin Screen (AEST-07 + UX-01)** - **D-21:** **Tasteful placeholder; Phase 3 paints.** Typographic title + a single "Begin" affordance, calls `AudioContext.resume()` on tap. - **D-22:** **Begin screen shows on first run only.** Subsequent loads skip directly to the live garden; AudioContext enables on the first interaction. "First run" = no save exists. **Memory Journal (MEMR-04, MEMR-05)** - **D-23:** Journal affordance **reveals after the player's first harvest**, then is persistent. - **D-24:** Journal layout = **full-screen modal overlay**, fragments grouped by Season; back affordance returns to garden. DOM-rendered per MEMR-05. - **D-25:** Newly harvested fragments (in active play) **surface immediately in a full-text reveal modal**; dismissing files into the journal. Offline auto-harvested fragments are surfaced via the *letter* and re-readable in the journal. **Visual Placeholders (Phase 2 only)** - **D-26:** Plants render as **simple Phaser-primitive shapes per growth stage, tinted by plant type**. No PNG asset work in Phase 2. - **D-27:** Ready-state cue = **subtle glow / pulse on ready tiles**. **Phase 2 Settings UI Scope** - **D-28:** Settings menu in Phase 2 ships **save-management surfaces only** — Export, Import, Restore. Persistence-result toast folds in. - **D-29:** Settings access = **small icon in a corner of the main view + keyboard shortcut**. Persistent. - **D-30:** `navigator.storage.persist()` outcome surfaced as a **one-time soft toast in voice on first save if denied; nothing if granted**. **Foundations That Must Land in Phase 2 (per CLAUDE.md)** - **D-31:** **`BigQty` wrapper around `break_eternity.js`** is Phase 2's first task. Lands in `src/sim/numbers/`. - **D-32:** **Zustand 5 store** is the bridge between Phaser scene and React UI. Sim writes to the store; React reads. Sim never imports the store directly. - **D-33:** **Tick scheduler / monotonic clock** is the *only* owner of wall-clock access. Tick rate is Claude's discretion (likely 4–10Hz). Negative deltas refused; offline catch-up clamps to 24h. - **D-34:** **Save extension for Phase 2** updates `V1Payload` (in `src/save/migrations.ts`) — Phase-2-scope schema *extension* (not a v1→v2 migration). ### Claude's Discretion - Specific growth-duration values per plant type within the 2–5min band (D-08 / D-09). - Exact fragment-count threshold values for plant-type unlocks (D-05) and Lura beats (1st/4th/8th may shift ±1–2 during playtest). - Form of the post-harvest acknowledgement beat (D-07). - Form of the gate indicator when Lura's beat unlocks (D-15). - Tick rate / sim cadence (likely 4–10Hz). - Internal shape of the Zustand store slices. - Internal shape of the scene/state machine inside Phaser (Boot → Preloader → Garden, or simpler). - Specific copy of the Begin screen, the persistence-denied toast, and the post-harvest acknowledgement (all reviewed by user). - Choice of e2e test fast-forward mechanism (hidden dev hotkey vs URL flag vs sim-clock injection). - Specific copy of the Memory Journal "no fragments yet" empty state. - Whether the offline letter's slot vocabulary is finalized in this phase or expanded incrementally. ### Deferred Ideas (OUT OF SCOPE) - Hybrid Lura presence (gate visits + ambient text-message drip) — rejected for Phase 2 in favor of pure discrete gate visits (D-12). May be reconsidered Phase 4+. - Plant-type unlocks tied to specific authored fragments — rejected for Phase 2 in favor of fragment-count thresholds (D-05). Phase 4+ may explore narrative-keyed unlocks. - Fully procedural letter from event-log templates — rejected (D-17). Phase 2 commits to authored skeleton + slots. - Audio sliders (UX-04), keyboard nav (UX-06), browser-zoom guarantees (UX-07), color-redundant icons (UX-08), tab-title bloom (UX-09), Lura-not-numbers UX (UX-12) — all confirmed for **Phase 8**. - Visual regression for asset library (PIPE-04) — Phase 8. - Roothold prestige currency, Season transitions, die-off, finite-ceiling enforcement — Phase 4. Phase 2 plants nothing in the save schema for Roothold. - Cross-pollination, Memory Storms, place-memory vignettes, ecosystem planting, the Below, the Loom, the Archivist, Lura's full multi-Season arc, the Nameless Man — Phase 4–7. - Watercolor post-process, painted plants, painted Begin screen, solo cello + ambient buses + crossfade, reduced-motion toggle (UX-05) — Phase 3. - Real production-volume AI assets + locked north-star reference set — Phase 5 follow-up. Phase 2 ships zero AI-generated assets (D-26 = primitive shapes). - Real `migrate_v1_to_v2` — Phase 4. Phase 2 only extends `V1Payload` shape (D-34). - Per-plant duration variance via dynamic content authoring — out of scope. - Compost yielding seeds back — rejected (D-04 = infinite seeds). - Persistent Settings element on Begin screen — rejected (D-29). ## Phase Requirements | ID | Description | Research Support | |----|-------------|------------------| | CORE-02 | Deterministic, fixed-timestep sim advancing by elapsed real time. | Tick Scheduler section + Pattern: Fixed-Timestep Accumulator. | | CORE-03 | Closed-and-returned garden progresses by elapsed time, capped at 24h. | Tick Scheduler + Offline Catch-up section. | | CORE-11 | Sim refuses negative deltas + caps any single offline progression at 24h. | Tick Scheduler boundary checks + tests. | | GARD-01 | Plant a seed into an unoccupied tile. | Garden Sim section + Inline Seed Picker section + sim/garden module. | | GARD-02 | Plant has visible growth state, updates from save data on load, advances over time. | Plant State Machine section + render/garden + tick scheduler. | | GARD-03 | Harvest mature plant → exactly one fragment; tile empties. | Harvest Logic section + Fragment Selector section. | | GARD-04 | Compost an immature plant → tonal beat acknowledging the choice to let go. | Compost Logic section + UI overlay. | | MEMR-01 | Each harvest yields exactly one memory fragment from authored pool gated by Season + progression. | Fragment Selector section. | | MEMR-02 | Fragments authored in plain text (Markdown + frontmatter) under `/content/`, compiled per-Season at build time. | Phase 1 already shipped; Phase 2 drops Season 1 files. | | MEMR-03 | Stable string IDs (`season1.soil.first-bloom`). | FragmentSchema regex already enforces; Season 1 IDs follow convention. | | MEMR-04 | Memory Journal (React DOM panel) listing every collected fragment, organized by Season. | Memory Journal section. | | MEMR-05 | Player can read any collected fragment in full, selectable + copy-pasteable. | Memory Journal section (DOM, not canvas). | | MEMR-06 | Deterministic selector respecting authored gating + no-duplicates within playthrough until pool exhausted. | Fragment Selector section. | | STRY-01 | Lura appears at gate during Season 1 with text-message-cadence dialogue authored in Ink. | Lura's Season 1 Arc section + Ink Integration section. | | STRY-06 | All authored dialogue uses Ink (`.ink` → JSON for runtime via inkjs). | Ink Compilation Pipeline section. | | STRY-07 | Keeper has no name, no backstory, no dialogue beyond final binary choice (Phase 7). | Vacuously satisfied — Phase 2 authors no Keeper-spoken lines. | | STRY-10 | Story progression gates on tick count, not wall time. | Lura Beat Gating section (counts harvest events = sim ticks). | | AEST-07 | First screen is "Tend the garden / Begin" gesture gate that calls `AudioContext.resume()`. | Begin Screen section + AudioContext Bootstrap section. | | UX-01 | Single, clean "Begin" screen; UI grows as player progresses (A Dark Room rule). | Begin Screen section + Memory Journal reveal logic. | | UX-02 | Returning-player letter from the garden — written in voice, not a stat dump. | Letter-from-the-Garden section. | | UX-10 | Save on `visibilitychange` to hidden, on `beforeunload`, on Season transitions. | Save Lifecycle Hooks section. | | UX-11 | Numbers display in human-readable formats (1.2K, 4.5M, scientific past threshold). | BigQty section (`format()` method). | | PIPE-02 | Player loads only current Season at runtime; future Seasons not in initial bundle. | Per-Season Lazy Loading section. | | PIPE-07 | E2E smoke (Playwright) loads game, plants seed, harvests fragment, verifies persistence across reload. | Playwright E2E section + Sim-Clock Injection section. | ## Standard Stack ### Core (already locked + installed; verified against `package.json` 2026-05-09) | Library | Version | Purpose | Why Standard | |---------|---------|---------|--------------| | `phaser` | `^4.1.0` (installed: 4.1.0) [VERIFIED: package.json + npm view] | 2D game framework: scenes, input, rendering, time, tweens, events. Phase 2 uses scene tree, input system, basic graphics primitives, ready-pulse via tween. | Already locked in Phase 1; vertical-slice owner. | | `react` / `react-dom` | `^19.2.6` (installed: 19.2.6) [VERIFIED] | DOM overlay shell: Begin, Journal, Letter, Lura dialogue, Settings, seed picker. | Locked Phase 1. | | `zustand` | `^5.0.0` (npm view: 5.0.13 — needs install in Phase 2) [VERIFIED: npm registry] | Phaser↔React state bridge. | Locked Phase 1; install is Phase 2's first task per D-32. | | `break_eternity.js` | `^2.1.3` (npm view: 2.1.3 — needs install) [VERIFIED: npm registry] | Big-number library wrapped by `BigQty`. | Locked Phase 1; install is Phase 2's first task per D-31. | | `inkjs` | `^2.4.0` (installed: 2.4.0) [VERIFIED: package.json] | Runtime for compiled Ink stories. ESM imports `Story` from `inkjs` per `node_modules/inkjs/ink.d.mts`. | Locked Phase 1; runtime usage begins Phase 2. | | `inklecate` (devDep) | `^1.8.1` (installed: 1.8.1) [VERIFIED: package.json] | Build-time Ink compiler. CLI invoked from `npm run compile:ink`. | Locked Phase 1; first real usage Phase 2. | | `idb` | `^8.0.3` (installed) [VERIFIED] | Already wired by `src/save/`. | — | | `lz-string` | `^1.5.0` (installed) [VERIFIED] | Already wired by `src/save/codec.ts`. | — | | `crc-32` | `^1.2.2` (installed) [VERIFIED] | Already wired by `src/save/checksum.ts`. | — | | `zod` | `^4.4.3` (installed) [VERIFIED] | Already used by `src/content/schemas/` and `src/save/envelope.ts`. Phase 2 uses for `OfflineEvent` schema in V1Payload extension. | — | | `gray-matter` | `^4.0.3` (installed) [VERIFIED] | Already wired in `src/content/loader.ts` for Markdown frontmatter. | — | | `yaml` | `^2.8.4` (installed) [VERIFIED] | Already wired. | — | ### Supporting (already in devDeps) | Library | Version | Purpose | When to Use | |---------|---------|---------|-------------| | `vitest` | `^4.1.5` [VERIFIED: package.json] | Unit + integration tests for sim modules, BigQty, scheduler, fragment selector, save migrations, Zustand slices. | All Phase 2 plans add tests. | | `@playwright/test` | `^1.59.1` [VERIFIED] | E2E smoke (PIPE-07). | Wave 2 plan. | | `happy-dom` | `^20.9.0` [VERIFIED] | Vitest DOM env (already configured in `vitest.config.ts`). | UI component tests. | | `fake-indexeddb` | `^6.2.5` [VERIFIED] | Used by `src/save/db.test.ts`; Phase 2's Vitest tests for the extended save flow continue to use it. | All save-touching unit tests. | ### Howler.js — Phase 2 Stub Strategy Howler.js is the locked audio library per `.planning/research/STACK.md` and CLAUDE.md, but Phase 3 owns audio bus setup. **Phase 2 does NOT install or use Howler.** It still must satisfy AEST-07's "calls `AudioContext.resume()`". The minimum-viable approach: Phase 2's Begin screen creates a fresh `AudioContext` (or accesses an existing one if Phase 3 has shipped) and calls `.resume()`. No buffers are loaded, no sounds are played. Phase 3 will rewire the Begin screen to also boot Howler's master gain. Concretely: `src/ui/begin/use-audio-bootstrap.ts` exports `bootstrapAudioContext()` returning `Promise`. The function: 1. Lazily creates `new AudioContext()` (with `webkitAudioContext` fallback for Safari). 2. Calls `audioContext.resume()` and awaits it. 3. Returns the context (Phase 3 will retrieve it via the Zustand store and feed Howler). 4. On any error or unsupported browser, returns `null` and logs once. Phase 2's loop does not depend on audio working. [CITED: developer.chrome.com/blog/web-audio-autoplay] — Web Audio gesture requirement and `resume()` pattern. ### Installation (additions Phase 2 makes) ```bash npm install zustand@^5.0.0 npm install break_eternity.js@^2.1.3 ``` That is the entire dependency-installation surface for Phase 2. Everything else is already on disk from Phase 1. **Version verification (2026-05-09):** - `npm view zustand version` → `5.0.13` [VERIFIED 2026-05-09] - `npm view break_eternity.js version` → `2.1.3` [VERIFIED 2026-05-09] - `npm view phaser version` → `4.1.0` (already installed) [VERIFIED 2026-05-09] - `npm view inkjs version` → `2.4.0` (already installed) [VERIFIED 2026-05-09] - `npm view inklecate version` → `1.8.1` (already installed) [VERIFIED 2026-05-09] - `npm view howler version` → `2.2.4` (NOT installed in Phase 2; deferred to Phase 3) [VERIFIED 2026-05-09] ### Alternatives Considered (and rejected for Phase 2) | Instead of | Could Use | Tradeoff | |------------|-----------|----------| | Phaser 4 EventBus + Zustand | Mitt-only event bus | Mitt is a fine event emitter, but Phaser's official template already provides an `EventBus` (Phaser.Events.EventEmitter) — using it removes a dependency and matches the documented Phaser+React pattern. Persistent state still needs Zustand; events alone do not survive reloads. | | Zustand 5 react binding | Vanilla `createStore` only | Vanilla works fine for the sim side, but the React side needs the hook-style binding (`useStore`) for clean re-renders. Use Zustand 5's pattern: `createStore` from `zustand/vanilla` for the underlying store + `useStore` from `zustand` for React subscriptions. [CITED: zustand.docs.pmnd.rs/reference/apis/create-store] | | `BigQty` as direct re-export of `Decimal` | Hand-roll wrapper class | The wrapper is non-negotiable per CLAUDE.md ("Never raw `Decimal` values in app code"). Re-export-only would let raw Decimals leak into call sites. | | Inline R3F-style React-Phaser bridge | Phaser 4 official EventBus + `` | The official `phaser/template-react-ts` shape is what `src/PhaserGame.tsx` already implements. Replacing this would touch Phase-1 code unnecessarily. | | 60Hz sim tick | 4–10Hz sim tick | A 4–10Hz sim tick is the canonical idle-game rate per `.planning/research/ARCHITECTURE.md` Pattern 2 (5Hz example) and gives `24h × 3600 × 5 = 432,000` ticks per offline cap, comfortably catchable in <100ms. 60Hz would need 5.18M ticks for the same offline window — pointless cost when growth resolution is in minutes. | | Phaser scene tree Boot → Preloader → Garden | Single Garden scene | Phase 2 has near-zero asset loading (no PNG plants — D-26 says primitives only). A single `Garden` scene plus a tiny `Boot` is enough; Preloader becomes meaningful in Phase 3 when watercolor textures arrive. | ## Architecture Patterns ### System Architecture Diagram ``` BROWSER TAB ┌─────────────────────────────────────────────────────────────────────┐ │ React 19 DOM Overlay (z-index above canvas) │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ Begin │ │ Journal │ │ Letter │ │ Dialogue│ │ Settings │ │ │ │ Screen │ │ (modal) │ │ (modal) │ │ (overlay)│ │ (modal) │ │ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │ │ │ │ │ │ │ │ │ └────┬───────┴────┬───────┴────┬───────┘ │ │ ┌────┴────────────────┴────┐ ┌─────┴────────────┴─────┐ │ │ │ Inline Seed Picker │ │ HUD chrome (corner │ │ │ │ (DOM popover positioned │ │ icons + journal │ │ │ │ over Phaser tile) │ │ icon, optional) │ │ │ └────┬─────────────────────┘ └────────────┬───────────┘ │ │ │ │ │ │ │ read state, dispatch commands │ │ │ ↓ ↓ │ │ ┌────────────────────────────────────────────────────┐ │ │ │ Zustand 5 Store (createStore from zustand/vanilla,│ │ │ │ read in React via useStore) │ │ │ │ ┌─────────┐ ┌─────────┐ ┌──────────┐ ┌─────────┐ │ │ │ │ │ garden │ │ memory │ │ narrative│ │ session │ │ │ │ │ │ slice │ │ slice │ │ slice │ │ slice │ │ │ │ │ └─────────┘ └─────────┘ └──────────┘ └─────────┘ │ │ │ └─────────┬────────────────────────────────────┬─────┘ │ │ │ command queue (intent only) │ │ │ ↓ ↑ │ │ ┌─────────────────────────┐ ┌─────────────────────────┐ │ │ │ Phaser 4 Scene Tree │ │ Pure Sim (src/sim/) │ │ │ │ (src/game/, src/render/)│ read │ ┌──────────────────┐ │ │ │ │ ┌─────────────────┐ │ state │ │ scheduler │ │ │ │ │ │ Boot → Garden │←────┼─────────┼─│ (only owner of │ │ │ │ │ │ scene │ │ │ │ wall-clock) │ │ │ │ │ └─────────────────┘ │ │ └────────┬─────────┘ │ │ │ │ • tile rendering │ │ │ injects t │ │ │ │ • plant primitives │ write │ ↓ │ │ │ │ • ready-pulse tween │ state │ ┌────────────────┐ │ │ │ │ • pointerdown handler │←────────┼─│ simulate(s,dt) │ │ │ │ │ • Phaser EventBus │ events │ │ garden.tick │ │ │ │ │ (scene-ready, etc.) │ │ │ growth.advance│ │ │ │ └─────────────────────────┘ │ │ fragment.sel │ │ │ │ │ │ narrative.gate│ │ │ │ ┌──────────────────────────────────┐│ │ numbers/BigQty│ │ │ │ │ Browser event listeners ││ └────────────────┘ │ │ │ │ visibilitychange → save │└──────────┬──────────────┘ │ │ │ beforeunload → save │ │ │ │ │ click → AudioContext.resume() │ │ │ │ └──────────────────────────────────┘ │ │ │ │ │ │ ┌──────────────────────────────────────────────┴──────────────┐ │ │ │ src/save/ (Phase 1, frozen barrel) │ │ │ │ wrap/unwrap, snapshot, requestPersistence, │ │ │ │ exportToBase64/importFromBase64, openSaveDB, │ │ │ │ migrate, V1Payload (extended Phase 2) │ │ │ └────────────────────────────┬────────────────────────────────┘ │ │ ↓ │ │ ┌──────────────────────────────────┐ │ │ │ IndexedDB (primary) + │ │ │ │ LocalStorage fallback │ │ │ │ Stores: saves, save_snapshots │ │ │ └──────────────────────────────────┘ │ │ │ │ Build time: /content/seasons/01-soil/*.{md,yaml} │ │ /content/dialogue/season1/*.ink │ │ ── Vite import.meta.glob (per-Season lazy from Phase 2) ── │ │ ── inklecate compile → src/content/compiled-ink/*.ink.json ── │ └─────────────────────────────────────────────────────────────────────┘ ``` **Data flow summary:** 1. **Boot:** `main.tsx` mounts `App`; `App` mounts ``; `PhaserGame.tsx` creates Phaser; Boot scene transitions to Garden scene; Garden scene wires the tick scheduler into its `update()` callback. 2. **Resume from save:** `src/save/index.ts` is read; if a save exists, the envelope is unwrapped, migrated (still v1), and committed to the Zustand store; the scheduler computes elapsed-since-`lastTickAt`, clamps to 24h, runs the catch-up loop, and emits an `offlineEvents` aggregate that triggers the letter overlay if absence ≥ 5min. 3. **First run:** No save → Begin screen mounts; tap calls `AudioContext.resume()` and dismisses the Begin screen; Garden scene activates. 4. **Live tick:** Garden scene's `update(time, delta)` calls `scheduler.tick(now)`. Scheduler accumulates wall-clock `delta`, drains in fixed-size `TICK_MS` chunks, calls `simulate(state, dt, commands)`, applies the result to the Zustand store. Render reads from the store. 5. **Plant a seed:** Player clicks empty tile in Phaser → React seed picker pops up (positioned via tile→screen coord conversion provided by `src/render/garden/`) → click commits → command dispatched into the store → next sim tick the command is drained and applied. 6. **Harvest:** Player clicks ready tile → `harvest` command queued → simulate runs `harvestPlant` → fragment selector picks one fragment id → `harvestedFragmentIds` array gets a new entry → store updates → React renders fragment-reveal modal. 7. **Lura beat:** `narrative.gate` checks `harvestedFragmentIds.length` after each harvest; on count ∈ {1, 4, 8} (config-tunable), enqueues a `pendingLuraBeat: 'arrival'|'mid'|'farewell'` flag; the gate icon glows; player taps gate → React loads compiled `season1/lura-arrival.ink.json` into a fresh inkjs `Story`, sets variables from the store snapshot, drives the dialogue overlay. 8. **Save on visibility hidden / beforeunload:** Listener invokes the save layer's `wrap` + IndexedDB write path. Save is small (<10KB) so synchronous serialization completes well within `beforeunload`'s tight window. ### Recommended Project Structure (Phase 2 additions) ``` src/ ├── sim/ (NEW Phase 2 — was empty firewall dir) │ ├── numbers/ │ │ ├── big-qty.ts D-31; wrapper around break_eternity.js │ │ ├── big-qty.test.ts │ │ └── format.ts UX-11 human-readable display (1.2K, 4.5M, …) │ ├── scheduler/ │ │ ├── clock.ts D-33; the only owner of Date.now() │ │ ├── tick.ts fixed-timestep accumulator + simulate dispatcher │ │ ├── catchup.ts CORE-03/CORE-11; refuse negative dt; clamp 24h │ │ └── *.test.ts │ ├── garden/ │ │ ├── types.ts Tile + PlantInstance + PlantType │ │ ├── plants.ts 2–3 PlantType definitions (durations, fragmentPool) │ │ ├── growth.ts sprout → mature → ready state machine │ │ ├── commands.ts plantSeed / harvest / compost (pure) │ │ ├── auto-harvest.ts D-10 offline auto-harvest branch │ │ └── *.test.ts │ ├── memory/ │ │ ├── selector.ts MEMR-06 deterministic, gating, no-dup │ │ ├── selector.test.ts │ │ └── pool.ts reads from src/content/, applies Season + plant gate │ ├── narrative/ │ │ ├── lura-gate.ts D-14 1st/4th/8th harvest count → pending beat │ │ ├── lura-gate.test.ts │ │ └── beat-queue.ts store-shape contract for pending Lura beats │ ├── offline/ │ │ ├── events.ts OfflineEvent schema (zod) + aggregator │ │ └── events.test.ts │ ├── state.ts SimState root shape + serialization (matches V1Payload) │ └── index.ts barrel; the application layer imports from here │ ├── store/ (NEW Phase 2) │ ├── garden-slice.ts 16 tiles + plant-type unlocks + commands │ ├── memory-slice.ts harvested fragment ids + reveal modal state │ ├── narrative-slice.ts Lura beat queue + dialogue overlay state │ ├── session-slice.ts Begin gate state + persistence-result toast │ ├── store.ts composes slices with createStore from zustand/vanilla │ ├── selectors.ts React-friendly selectors for components │ └── index.ts │ ├── save/ (FROZEN Phase 1 barrel; Phase 2 only edits migrations.ts) │ └── migrations.ts extends V1Payload per D-34 │ ├── content/ (FROZEN Phase 1 barrel; Phase 2 adds Season-1 lazy split) │ ├── ink-loader.ts (NEW) runtime loader for compiled-ink JSON │ ├── compiled-ink/ (GENERATED, gitignored) by `npm run compile:ink` │ │ └── season1/ │ │ ├── lura-arrival.ink.json │ │ ├── lura-mid.ink.json │ │ ├── lura-farewell.ink.json │ │ └── letter-from-the-garden.ink.json │ └── (existing Phase 1 modules unchanged) │ ├── render/ │ └── garden/ │ ├── tile-renderer.ts D-06 outlined tile, hover state │ ├── plant-renderer.ts D-26 primitive shapes per growth stage │ ├── ready-pulse.ts D-27 alpha cycle / shader pulse │ ├── gate-renderer.ts gate visual + indicator on pending Lura beat (D-15) │ └── tile-coords.ts tile↔screen coord conversion (used by seed picker) │ ├── ui/ │ ├── begin/ │ │ ├── BeginScreen.tsx D-21 typographic placeholder │ │ └── use-audio-bootstrap.ts AudioContext.resume() on tap │ ├── journal/ │ │ ├── Journal.tsx D-24 full-screen modal │ │ ├── FragmentRevealModal.tsx D-25 active-play harvest reveal │ │ └── journal-icon.tsx D-23 reveals after 1st harvest │ ├── letter/ │ │ ├── Letter.tsx D-20 full-screen, ≥5min absence │ │ └── letter-renderer.ts drives inkjs Story + variable bindings │ ├── dialogue/ │ │ ├── LuraDialogue.tsx D-15 React DOM overlay │ │ ├── ink-renderer.tsx text-message-cadence drip │ │ └── ink-runtime.ts inkjs Story instantiation + variable wiring │ ├── settings/ │ │ ├── Settings.tsx D-28 Export/Import/Restore │ │ └── persistence-toast.tsx D-30 in-voice soft toast │ └── garden/ │ └── SeedPicker.tsx D-02 inline popover positioned over canvas │ ├── game/ │ ├── main.ts EXPAND scene list to include Garden │ ├── event-bus.ts (NEW) Phaser.Events.EventEmitter singleton (per Phaser 4 template) │ └── scenes/ │ ├── Boot.ts EXPAND: transition to Garden after `MainScene.create` │ └── Garden.ts (NEW) the canvas scene; wires tick scheduler + tile input │ └── PhaserGame.tsx EXPAND: subscribe to event-bus, expose audio context, register lifecycle save hooks ``` ``` content/ ├── seasons/ │ ├── 00-demo/ REMOVED Phase 2 (per CONTEXT canonical_refs) │ └── 01-soil/ NEW │ ├── fragments.yaml bulk Season-1 fragments (≥8 to satisfy 8th-harvest threshold) │ └── fragments/ │ └── *.md long-form fragments (one-per-file, frontmatter) └── dialogue/ NEW (was empty) └── season1/ ├── lura-arrival.ink ├── lura-mid.ink ├── lura-farewell.ink └── letter-from-the-garden.ink ``` ``` tests/ └── e2e/ NEW Phase 2 └── season1-loop.spec.ts PIPE-07 smoke ``` ### Pattern 1: Tick Scheduler / Monotonic Clock **What:** A scheduler module in `src/sim/scheduler/` that owns all wall-clock access (`Date.now()` / `performance.now()`), accumulates real-time deltas, drains them in fixed-size `TICK_MS` chunks, and calls `simulate(state, dtTicks, commands) → state'`. It is the single boundary point between wall time and sim time. **When to use:** Always for the live tick. Same module also handles offline catch-up. **Recommended tick rate:** 5 Hz (`TICK_MS = 200`). Rationale: - A 24h offline catch-up = `24 × 3600 × 5 = 432,000` ticks. Even at 1µs per tick, that catches up in ~0.4s — well within "boot to playable". - Plant growth in the 2–5min band has natural granularity (a 5-min plant ticks 1500 times sprout→ready; plenty of resolution for sprout/mature/ready transitions). - Render runs at `requestAnimationFrame` (60Hz on most displays); sim runs at 5Hz independently. They're decoupled by design. - 5Hz matches the example in `.planning/research/ARCHITECTURE.md` Pattern 2 — keeping the documented rate keeps everyone on the same page. **Example:** ```typescript // Source: .planning/research/ARCHITECTURE.md Pattern 2 + CONTEXT D-33 // src/sim/scheduler/tick.ts — pure, no I/O. Time is INJECTED. import type { SimState } from '../state'; import { simulate } from '../simulate'; export const TICK_MS = 200; // 5Hz export const MAX_OFFLINE_MS = 24 * 3600 * 1000; export interface TickResult { state: SimState; ticksApplied: number; silent: boolean; } /** * Drain the accumulator. Called by the Phaser scene's update() loop OR by * the catch-up function during boot. Returns the new state and how many * ticks were actually applied. * * REFUSES negative deltas (CORE-11); CLAMPS at MAX_OFFLINE_MS (CORE-03). */ export function drainTicks( state: SimState, accumulatorMs: number, silent = false, ): { state: SimState; remainderMs: number; ticksApplied: number } { if (accumulatorMs < 0) { // System-clock cheat or save-corruption case. Sim refuses to advance. // The application layer logs and resets the accumulator. return { state, remainderMs: 0, ticksApplied: 0 }; } const cappedMs = Math.min(accumulatorMs, MAX_OFFLINE_MS); const ticks = Math.floor(cappedMs / TICK_MS); let next = state; for (let i = 0; i < ticks; i++) { next = simulate(next, TICK_MS, /* commands */ [], { silent }); } return { state: next, remainderMs: cappedMs - ticks * TICK_MS, ticksApplied: ticks, }; } ``` ```typescript // src/sim/scheduler/clock.ts — the ONLY allowed Date.now() in the project. export interface Clock { now(): number; // monotonic-enough wall clock in ms } /** Production clock. Reads Date.now(). */ export const wallClock: Clock = { now: () => Date.now(), }; /** Test clock. Lets Vitest tests advance time deterministically. * Also used by Playwright's fast-forward URL flag (?devtime=fake). */ export class FakeClock implements Clock { private t: number; constructor(start = 0) { this.t = start; } now(): number { return this.t; } advance(ms: number): void { this.t += ms; } } ``` The Phaser `Garden` scene's `update(_time, _delta)` calls `scheduler.advanceLive(clock.now())` once per frame; the scheduler owns the `lastFrameTime` and the accumulator. Note: Phaser's `time` and `delta` arguments are also wall-derived, but we ignore them and call `clock.now()` ourselves so a `FakeClock` injection is the *only* knob. [CITED: gafferongames.com/post/fix_your_timestep — Fix Your Timestep accumulator pattern, the canonical reference.] **Boot path (catch-up from save):** ```typescript // Application layer — src/app/boot.ts (or inlined in PhaserGame.tsx). // 1. Read save (src/save/). // 2. Migrate (still v1). // 3. Compute offlineMs = clock.now() - savedPayload.lastTickAt. // 4. drainTicks(state, offlineMs, /* silent */ true) → aggregates events. // 5. If offlineMs >= 5*60*1000 (D-20 threshold), pop the Letter overlay. // 6. Commit final state to the Zustand store; live tick begins. ``` **Anti-patterns to avoid:** - `setInterval(tick, 200)` — breaks under tab throttling (`.planning/research/PITFALLS.md` #12). - Calling `Date.now()` anywhere in `src/sim/garden/`, `src/sim/memory/`, `src/sim/narrative/` — would silently bypass the FakeClock. The ESLint `no-restricted-syntax` rule should be added for `CallExpression[callee.object.name='Date'][callee.property.name='now']` inside `src/sim/**` except `src/sim/scheduler/clock.ts`. ### Pattern 2: BigQty wrapper around break_eternity.js **What:** A typed wrapper around `Decimal` (the class exported from `break_eternity.js`) that all economic values flow through. Phase 2 lands this even though Season 1 numbers never exceed `Number.MAX_SAFE_INTEGER` — per CLAUDE.md "BigNumbers go through the typed BigQty wrapper around break_eternity.js. Never raw Decimal values in app code." **Minimum-viable surface:** ```typescript // Source: .planning/research/PITFALLS.md #7 + CLAUDE.md "Code Style" // src/sim/numbers/big-qty.ts import Decimal from 'break_eternity.js'; // ships index.d.ts; no @types needed export class BigQty { private constructor(private readonly d: Decimal) {} // Constructors static fromNumber(n: number): BigQty { return new BigQty(new Decimal(n)); } static fromString(s: string): BigQty { return new BigQty(new Decimal(s)); } static zero(): BigQty { return BigQty.fromNumber(0); } static one(): BigQty { return BigQty.fromNumber(1); } // Arithmetic (returns NEW BigQty — immutable) add(b: BigQty): BigQty { return new BigQty(this.d.add(b.d)); } sub(b: BigQty): BigQty { return new BigQty(this.d.sub(b.d)); } mul(b: BigQty): BigQty { return new BigQty(this.d.mul(b.d)); } div(b: BigQty): BigQty { return new BigQty(this.d.div(b.d)); } // Comparison eq(b: BigQty): boolean { return this.d.eq(b.d); } gte(b: BigQty): boolean { return this.d.gte(b.d); } gt(b: BigQty): boolean { return this.d.gt(b.d); } lt(b: BigQty): boolean { return this.d.lt(b.d); } lte(b: BigQty): boolean { return this.d.lte(b.d); } // Display (UX-11) format(): string { return formatHumanReadable(this.d); } toNumberSaturating(): number { // Phase 2: returns safe-int saturating value for UI bookkeeping. if (this.d.gte(Number.MAX_SAFE_INTEGER)) return Number.MAX_SAFE_INTEGER; return this.d.toNumber(); } // Serialization (round-trips through Save) toJSON(): string { return this.d.toString(); } static fromJSON(s: string): BigQty { return BigQty.fromString(s); } } // src/sim/numbers/format.ts — UX-11 export function formatHumanReadable(d: Decimal): string { // Phase 2 minimum: K/M/B/T then scientific past 1e15. const n = d.toNumber(); if (Number.isFinite(n) && Math.abs(n) < 1000) return n.toFixed(0); if (Math.abs(n) < 1e6) return `${(n / 1e3).toFixed(1)}K`; if (Math.abs(n) < 1e9) return `${(n / 1e6).toFixed(1)}M`; if (Math.abs(n) < 1e12) return `${(n / 1e9).toFixed(1)}B`; if (Math.abs(n) < 1e15) return `${(n / 1e12).toFixed(1)}T`; // break_eternity-only territory: scientific via Decimal#toExponential return d.toExponential(2); } ``` **Why an immutable wrapper:** every operation returns a fresh `BigQty`, so the sim's state shape is purely value-oriented. This composes with Zustand 5's structural-equality detection (no accidental mutation tripping subscriber loops) and with snapshot persistence (the save layer just calls `JSON.stringify` on values that include `BigQty` instances — `toJSON()` is invoked automatically). **Save round-trip:** `BigQty.toJSON()` returns the canonical Decimal string. On load, `BigQty.fromJSON(s)` reconstructs. This must round-trip in all migration tests; add a Vitest case in `migrations.test.ts` once Phase 4 starts using BigQty fields. **Phase 2 actual usage sites:** Phase 2's economy is intentionally minimal — the only BigQty-typed values are likely: - `harvestCount` (could remain plain `number`; promoting to BigQty here is the discipline-tax that buys retrofit safety later). - The (deferred-to-Phase-4) Roothold value — Phase 2 does NOT add it to V1Payload, but the wrapper is ready when it does. [CITED: github.com/Patashu/break_eternity.js — TypeScript-native via `index.d.ts`; v2.1.3.] ### Pattern 3: Zustand 5 + Phaser EventBus Bridge **What:** Two communication channels between Phaser scenes and React UI: 1. **Persistent state** lives in a Zustand 5 store created via `createStore` from `zustand/vanilla` (works without React). The sim writes to it via a slim adapter; React components read from it via the `useStore` hook. 2. **Transient events** ("scene-ready", "tile-clicked-with-coords") flow through a `Phaser.Events.EventEmitter` singleton — the official Phaser 4 React-template `EventBus` pattern. [CITED: phaser.io/news/2025/05/bringing-our-phaserjs-templates-into-the-future] **Why both:** Persistent state needs structural-equality reactivity for component re-renders (Zustand). One-shot signals (e.g., "active scene is now Garden", or "popover should appear at screen X,Y" because Phaser knows where the tile is on screen) don't need to live in state — they're transients. Routing transients through Zustand pollutes the store. **Slice composition:** ```typescript // Source: zustand.docs.pmnd.rs/reference/apis/create-store // src/store/store.ts import { createStore } from 'zustand/vanilla'; import { useStore } from 'zustand'; import type { GardenSlice } from './garden-slice'; import type { MemorySlice } from './memory-slice'; import type { NarrativeSlice } from './narrative-slice'; import type { SessionSlice } from './session-slice'; export type AppStoreShape = GardenSlice & MemorySlice & NarrativeSlice & SessionSlice; export const appStore = createStore()((set, get) => ({ ...createGardenSlice(set, get), ...createMemorySlice(set, get), ...createNarrativeSlice(set, get), ...createSessionSlice(set, get), })); // React-side hook export function useAppStore(selector: (s: AppStoreShape) => T): T { return useStore(appStore, selector); } // Sim-side adapter (sim cannot import this directly per CORE-10 firewall; // the adapter sits in src/store/ and exposes a write-only interface that // the Phaser scene injects into the scheduler call site). export const simAdapter = { applySimResult: (next: SimState, events: SimEvent[]) => { appStore.setState({ /* derive slice updates from next + events */ }); }, drainCommands: () => { const cmds = appStore.getState().pendingCommands; appStore.setState({ pendingCommands: [] }); return cmds; }, }; ``` **Slice example:** ```typescript // src/store/garden-slice.ts export interface GardenSlice { tiles: Tile[]; // length 16, per D-01 unlockedPlantTypes: PlantTypeId[]; pendingCommands: GardenCommand[]; enqueuePlant: (tileIdx: number, plantTypeId: PlantTypeId) => void; enqueueHarvest: (tileIdx: number) => void; enqueueCompost: (tileIdx: number) => void; } ``` **Phaser EventBus singleton:** ```typescript // Source: phaser.io/news/2025/05/bringing-our-phaserjs-templates-into-the-future // src/game/event-bus.ts import * as Phaser from 'phaser'; /** Single shared emitter — the Phaser 4 React-template pattern. */ export const eventBus = new Phaser.Events.EventEmitter(); // Sample events Phase 2 will emit/listen for: // 'scene-ready' (Phaser → React) signals scene tree is live // 'tile-clicked-coords' (Phaser → React) {tileIdx, screenX, screenY} for seed picker // 'request-active-scene' (React → Phaser) one-shot // 'fragment-revealed' (Phaser → React) one-shot for D-25 reveal modal ``` The Phaser scene calls `eventBus.emit('scene-ready', this)`; `PhaserGame.tsx` listens, surfaces the active scene to React. This is exactly what `src/PhaserGame.tsx`'s Phase-1 placeholder is shaped for (the `currentActiveScene` callback comment). **Anti-pattern:** routing user-input intents through the EventBus instead of the store. User intents are commands (durable, replayable, save-able if needed); transient signals are not. Tile-clicks → store. Scene transitions → EventBus. ### Pattern 4: Inline Seed Picker (DOM popover over Phaser canvas) **What:** D-02's "click empty tile → inline seed picker" with no persistent sidebar. Implementation: pointerdown on a Phaser tile fires an EventBus event with `{tileIdx, screenX, screenY}`; the React `` listens, mounts itself absolutely-positioned to those screen coords with a CSS arrow pointing at the tile. ```typescript // src/render/garden/tile-coords.ts export function tileToScreenCoords( scene: Phaser.Scene, tileIdx: number, ): { x: number; y: number } { const tile = /* Phaser GameObject for tileIdx */; // Account for the Scale.FIT center offset and DPI. const camera = scene.cameras.main; return { x: camera.x + tile.x * camera.zoom, y: camera.y + tile.y * camera.zoom, }; } // In Garden scene's create(): this.tileObjects.forEach((t, idx) => { t.setInteractive(); t.on('pointerdown', (pointer: Phaser.Input.Pointer) => { if (state.tiles[idx].plant === null) { eventBus.emit('tile-clicked-coords', { tileIdx: idx, screenX: pointer.x, // pointer event coords are in canvas space screenY: pointer.y, }); } else if (state.tiles[idx].plant.stage === 'ready') { appStore.getState().enqueueHarvest(idx); } // Mature-but-not-ready tile click → no-op or compost confirmation. }); }); ``` [CITED: docs.phaser.io/phaser/concepts/input — Phaser input system; pointerdown on interactive game objects.] **Position translation:** `pointer.x/y` from a Phaser pointer event are in the canvas's CSS-pixel coordinate space, which matches DOM coordinates. The `` mounts at `position: absolute; left: ${pointer.x}px; top: ${pointer.y}px;` directly. (No need to `getBoundingClientRect` the canvas; the canvas's CSS layout starts at viewport origin in this app.) **Click outside → dismiss:** the popover registers a one-shot document `click` listener that fires after the next tick (avoids capturing the same click that opened it). ### Pattern 5: Ink Compilation + Runtime **Build-time:** `inklecate` (installed as devDep) compiles `*.ink` → `*.ink.json`. The current `package.json scripts.compile:ink` is a no-op stub from Phase 1; Phase 2 replaces it: ```json "compile:ink": "node scripts/compile-ink.mjs" ``` ```javascript // scripts/compile-ink.mjs (NEW Phase 2) // Walks /content/dialogue/**/*.ink, calls inklecate per file, // emits to src/content/compiled-ink//.ink.json. // Adds --countAllVisits=false (default) so visit counts are minimal. import { inklecate } from 'inklecate'; import { glob } from 'glob'; import path from 'node:path'; const inkFiles = await glob('content/dialogue/**/*.ink'); for (const inkPath of inkFiles) { const rel = path.relative('content/dialogue', inkPath); const outPath = path .join('src/content/compiled-ink', rel) .replace(/\.ink$/, '.ink.json'); await inklecate({ inputFilepath: inkPath, outputFilepath: outPath, }); } ``` This script runs as part of `npm run build` (and explicitly via `npm run compile:ink` so dev iteration is fast). The output directory is `.gitignore`'d; the Vite dev server serves the compiled JSON via standard import. **Runtime:** `inkjs` v2.4.0 exports `Story` from the package root (verified in `node_modules/inkjs/ink.d.mts`). Usage: ```typescript // Source: github.com/y-lohse/inkjs README; node_modules/inkjs/README.md // src/ui/dialogue/ink-runtime.ts import { Story } from 'inkjs'; // Lazy import the compiled JSON so Season 2-7 stays out of the Phase-2 bundle. const luraStories = import.meta.glob('/src/content/compiled-ink/season1/lura-*.ink.json', { import: 'default', }); export async function loadLuraBeat(beatId: 'arrival' | 'mid' | 'farewell'): Promise { const path = `/src/content/compiled-ink/season1/lura-${beatId}.ink.json`; const json = await luraStories[path](); return new Story(json); } // Variable wiring — Lura's branches can read game state. export function bindGardenStateToInk(story: Story, snapshot: AppStoreShape): void { // Map from store keys to Ink variable names declared in the .ink files. story.variablesState['fragment_count'] = snapshot.harvestedFragmentIds.length; story.variablesState['last_plant_type'] = snapshot.lastPlantedType ?? ''; // ... add more as the writer adds Ink variables } ``` **Drip-fed text-message-cadence rendering (STRY-01 "text-message-cadence dialogue"):** Phase 2's React `` component calls `story.Continue()` to fetch the next line, awaits a tunable delay (e.g., 800ms × line length / 40 characters), then renders. Choices are rendered as buttons after `story.currentChoices` resolves. The reduced-motion preference (deferred to Phase 8) will eventually short-circuit the delay; Phase 2 ships a fixed cadence. ### Pattern 6: Letter-from-the-Garden as Ink Template **What:** D-17 says the letter is an authored Ink skeleton with named variable slots. Ink supports variables via `VAR varName = value` syntax and string interpolation `{varName}` in passages. The application sets the variables via `story.variablesState['name'] = value` BEFORE calling `Continue()`. **Skeleton example (`content/dialogue/season1/letter-from-the-garden.ink`):** ```ink VAR plants_bloomed = 0 VAR fragment_titles = "" VAR lura_was_here = false The garden held its breath while you were gone. {plants_bloomed > 1: {plants_bloomed} blooms came and went, leaves heavy with their leaving. - else: {plants_bloomed == 1: One bloom came and went. - else: Nothing bloomed. The wind carried something else, and the garden remembered it. } } { fragment_titles ? "Among what stayed: {fragment_titles}." } { lura_was_here: Lura came by once. She did not knock. She left a folded leaf on the gate post. } ``` **Why Ink variables, not Ink EXTERNAL functions:** EXTERNAL functions are for callbacks ("game, please tell me X"); they're heavier and require runtime registration. Simple string-substitution slots map cleanly to `variablesState[name] = value`. Phase 2 ships variable-substitution only. If Phase 4+ needs more, the `EXTERNAL function get_lura_quote() = "..."` pattern is available without re-architecting. **Slot vocabulary for Phase 2's letter** (from D-19's `offlineEvents` block): - `plants_bloomed: number` — total auto-harvested plants during absence - `fragment_titles: string` — comma-joined human-readable list of just-collected fragments (or `""` if none) - `lura_was_here: boolean` — whether a beat ticked while the player was away (the gate indicator was queued) The writer expands the slot list as new Ink expressions need state; Phase 2 may grow from these three to ~5–6 by end of phase. CLAUDE.md "voice anchor" rule means the Ink reads as authored fiction; the slots are filled at runtime. ### Pattern 7: Save-Schema Extension (NOT a migration) **What:** Per CONTEXT D-34 and `.planning/phases/01-foundations-and-doctrine/01-CONTEXT.md` D-04, Phase 1's v1 envelope has shipped no production saves. Phase 2 *extends* `V1Payload` in place — adds new fields with sensible defaults — and the existing `migrations[1]` synthetic v0→v1 demo continues to work because v0 → v1 already sets up the new defaults via the same factory function. **Concrete edit to `src/save/migrations.ts`:** ```typescript // Phase-2 V1Payload shape. EXTENSION of Phase-1's shape (compatible). export interface V1Payload { garden: { tiles: TileSlot[] }; plants: PlantInstance[]; // shape filled in Phase 2 harvestedFragmentIds: string[]; lastTickAt: number; // NEW Phase 2 fields: unlockedPlantTypes: PlantTypeId[]; // D-05 luraBeatProgress: { arrived: boolean; mid: boolean; farewell: boolean; pending: 'arrival' | 'mid' | 'farewell' | null; }; // D-13/D-14 offlineEvents: OfflineEventBlock | null; // D-19; nulled after letter dismissed settings: { musicVolume: number; ambientVolume: number; sfxVolume: number; persistenceToastShown: boolean; // D-30 one-time }; } // migrations[1] becomes: export const migrations: Record = { 1: (s: unknown): V1Payload => { const v0 = (s ?? {}) as V0Payload; return { garden: { tiles: v0.garden ?? [] }, plants: [], harvestedFragmentIds: [], lastTickAt: Date.now(), // NEW defaults: unlockedPlantTypes: [], // populated by sim init when first run luraBeatProgress: { arrived: false, mid: false, farewell: false, pending: null }, offlineEvents: null, settings: { musicVolume: 0.7, ambientVolume: 0.5, sfxVolume: 0.8, persistenceToastShown: false, }, }; }, }; ``` The `migrations.test.ts` tests need updating for the new fields, but `CURRENT_SCHEMA_VERSION` stays at `1` and no `migrations[2]` is added (per `.planning/phases/01-foundations-and-doctrine/01-CONTEXT.md` D-04: "The first *real* migration is v1 → v2 in Phase 4"). **Why this is safe:** No production saves under v1 exist. Adding fields to `V1Payload` is type-extension, not data migration. The synthetic v0 → v1 chain is updated to populate the new defaults; the chain remains exercised end-to-end. **Anti-patterns to avoid:** - Adding a `migrations[2]` for what is fundamentally a Phase-2 init concern. Phase 4 owns `migrations[2]` per Phase 1's locked decision. - Reading `lastTickAt` directly from save without going through the scheduler — this would let `Date.now()` leak into sim modules. - Storing the offline letter's *body text* in the save. Only the structured `offlineEvents` block is saved; the Ink template renders it on demand from `/content/dialogue/season1/letter-from-the-garden.ink.json`. ### Pattern 8: Per-Season Lazy Loading (PIPE-02) **What:** `import.meta.glob('/content/seasons/*/fragments/*.md', { eager: false, … })` returns functions that resolve to dynamic imports — Vite splits each into its own chunk. [CITED: vite.dev/guide/features — `import.meta.glob` is "lazy-loaded via dynamic import" by default when `eager` is not set or is `false`.] The current `src/content/loader.ts` uses `eager: true` for Phase 1's single-Season demo. Phase 2 splits the Season-1 path into a lazy variant: ```typescript // src/content/loader.ts (Phase 2 evolution) // Eager bootstrap: only the season manifest (lightweight) is in the initial bundle. const seasonManifests = import.meta.glob('/content/seasons/*/manifest.yaml', { eager: true, query: '?raw', import: 'default', }) as Record; // Per-Season lazy: actual fragment files load only when that Season is active. const lazyYamlFragments = import.meta.glob('/content/seasons/*/fragments.yaml', { query: '?raw', import: 'default', }); const lazyMdFragments = import.meta.glob('/content/seasons/*/fragments/*.md', { query: '?raw', import: 'default', }); export async function loadSeasonFragments(seasonId: number): Promise { const yamlPath = `/content/seasons/${pad2(seasonId)}-${slug(seasonId)}/fragments.yaml`; const fragmentPaths = Object.keys(lazyMdFragments).filter(p => p.includes(`/${pad2(seasonId)}-`)); // …load + validate each via FragmentSchema as today; throw on schema violation. } ``` For Phase 2 (Season 1 only is authored), Vite produces two chunks: the main bundle (no fragment text) and a `season1` chunk (~few KB of Markdown + YAML). When Phase 4 adds Season 2, it ships an additional chunk; Season 2-7 are never in the initial download until the player progresses. **Initial-load budget impact:** Phase 1's initial bundle is dominated by Phaser 4 (~700KB minified) and React 19 (~50KB). Phase 2 adds Zustand (~3KB), `break_eternity.js` (~30KB), Season-1 Ink JSON (~few KB), and roughly 200 lines of Phase-2 sim code. The Season-1 fragment chunk lazy-loads after the Begin gesture, so the *gesture-to-painted-garden* path stays under CORE-01's 5s budget. ### Pattern 9: AudioContext.resume() Bootstrap **Per CONTEXT D-21/D-22:** - First-run (no save exists): Begin screen mounts; tap calls `audioContext.resume()`. - Subsequent loads: Begin screen is skipped; AudioContext starts in `suspended` state and resumes on the player's first interaction (any tile click, gate click, or button click counts as a user gesture per browser autoplay policy). ```typescript // src/ui/begin/use-audio-bootstrap.ts let _ctx: AudioContext | null = null; let _resumed = false; export async function bootstrapAudioContext(): Promise { if (_resumed && _ctx) return _ctx; if (!_ctx) { try { const Ctor = typeof AudioContext !== 'undefined' ? AudioContext : (window as any).webkitAudioContext; if (!Ctor) return null; _ctx = new Ctor(); } catch { return null; } } try { await _ctx.resume(); _resumed = true; return _ctx; } catch { return null; } } // First-interaction handler installed on the live garden when no Begin shows. export function installFirstInteractionGestureHandler(): void { const handler = () => { bootstrapAudioContext(); document.removeEventListener('click', handler); document.removeEventListener('touchstart', handler); document.removeEventListener('keydown', handler); }; document.addEventListener('click', handler, { once: false }); document.addEventListener('touchstart', handler, { once: false }); document.addEventListener('keydown', handler, { once: false }); } ``` [CITED: developer.chrome.com/blog/web-audio-autoplay — explicit `context.resume()` after gesture is the canonical fix.] **Phase 3 will:** retrieve the resumed `AudioContext` from this module (or pass it through the Zustand store) and feed it to Howler's master gain. ### Anti-Patterns to Avoid - **`setInterval(tick, 200)` for sim progression** — breaks under tab throttling; the elapsed-time clock model is the documented fix (`.planning/research/PITFALLS.md` #12). - **Mutating store state from sim modules directly** — sim cannot import `src/store/`. Instead, the application layer (Phaser scene + scheduler) is the bridge: scene calls `scheduler.tick(state)`, gets back `next state`, calls `simAdapter.applySimResult(next, events)` which lives in `src/store/`. - **Naive offline catch-up** (`fragments += rate * elapsedSeconds`) — misses non-linear interactions and Lura beat unlocks. **Run the actual `simulate()` loop** with `silent: true` (`.planning/research/PITFALLS.md` Anti-Pattern 7). - **`if (!state.luraBeatProgress) state.luraBeatProgress = {...}` at read sites** — that's a hidden migration. Instead, fix the migration once in `migrations[1]` (Phase 2) and require `V1Payload` to be fully populated. - **Storing player-visible strings in TypeScript** — every line of Lura's dialogue, the letter, the Begin screen copy, the post-harvest acknowledgement, the persistence-denied toast lives in `/content/`. The "no fragments yet" empty-state copy lives in `/content/seasons/01-soil/ui-strings.yaml` (a small additional content file Phase 2 adds), validated by an additional Zod schema. - **Numeric fragment IDs** — already prevented by `FragmentSchema`'s regex. Don't loosen the regex for "convenience." - **Calling `audioContext.resume()` in `useEffect` without a user gesture** — fires before gesture, fails silently; the resume must be inside an actual click handler. - **Rendering Memory Journal text inside Phaser** — MEMR-05 demands selectable, copy-pasteable DOM text. Phaser canvas text fails this requirement. - **Lura's beats as `setTimeout` triggers** — would let system-clock manipulation skip beats. STRY-10 demands tick-count gating. The fragment-count-based trigger satisfies this naturally because harvest events advance during sim ticks only. - **Reading `Date.now()` inside a React render** — would re-fire on every render. Time is in the store; React just reads it. ## Don't Hand-Roll | Problem | Don't Build | Use Instead | Why | |---------|-------------|-------------|-----| | Big-number math for prestige economy | Custom BigDecimal | `break_eternity.js` (wrapped by `BigQty`) | Native JS numbers overflow at 1e308; precision loss at 1e15. `break_eternity.js` is the standard for incremental games (Pitfall 7). | | Branching dialogue with state-aware choices | Custom dialogue tree | `inkjs` + `inklecate` | Already locked + installed. Ink is purpose-built for narrative scripting; `inkjs` runtime is battle-tested across browser deployments. (`.planning/research/STACK.md`) | | Deterministic fragment selection with gating + no-dup | Random `Math.random()` rolling | Pure function with seeded PRNG over a filtered, exhaustion-tracking pool | MEMR-06 demands "deterministic". Use a small seeded PRNG (e.g., mulberry32, ~10 LoC pure function) seeded from a stable hash of `(harvestedFragmentIds.length, lastTickAt)` so replays are reproducible. | | State management between Phaser and React | Custom event bus + manual state sync | Zustand 5 (createStore + useStore) + Phaser EventBus | Zustand is locked + standard. The Phaser `EventBus` pattern is the official template-react integration. | | Idle game tick loop with fixed timestep + offline catch-up | `setInterval` + ad-hoc math | Accumulator pattern owning wall-clock at one boundary | Tab throttling, system-clock cheat, save-load sync — all solved by the documented pattern (Architecture Pattern 2). | | Save versioning + checksum + Base64 + IndexedDB + LocalStorage fallback | Anything | `src/save/` (Phase 1) | Already shipped. Phase 2 imports only from `src/save/index.ts`. | | Markdown frontmatter parsing | Custom regex | `gray-matter` (already installed + wired) | Standard. Phase 1 already integrated it. | | YAML parsing | Custom YAML | `yaml` package (already installed + wired) | Standard. Phase 1 already integrated it. | | CRC-32 checksum | Custom CRC | `crc-32` package (already installed + wired) | Standard. Phase 1 already integrated it. | | Schema validation | Custom validator | `zod` (already installed + wired) | Standard. Phase 1 uses it for content + envelope; Phase 2 uses for `OfflineEventBlock`. | | Audio crossfading | Custom Web Audio gain ramps | `Howler.js` (Phase 3) — Phase 2 stubs only | Howler abstracts iOS Safari quirks. Phase 2 doesn't need it; Phase 3 wires it. | | Ink dialogue rendering with text-cadence | Custom drip animator | Compose `setTimeout` cadence around `inkjs.Story.Continue()` (~30 LoC) | The drip is genuinely small custom code; the dialogue *engine* is Ink. | | Per-Season lazy loading | Custom dynamic-import scheme | `import.meta.glob({ eager: false })` — Vite native | Vite splits chunks automatically. (`.planning/research/STACK.md` + Vite docs) | **Key insight:** Phase 1 already paid for the heavy infrastructure. Phase 2's "don't hand-roll" list is mostly "use what Phase 1 built" — the temptation will be to bypass the existing barrel and import from internal modules under deadline pressure. Don't. ## Common Pitfalls ### Pitfall 1: Sim modules calling `Date.now()` for "convenience" **What goes wrong:** A sim module reads `Date.now()` to "stamp" a plant's plantedAt. The Vitest test passes (because `Date.now()` exists in node). The Playwright fast-forward fails because the FakeClock injection is bypassed. **Why it happens:** It's one line of "obvious" code. Authors don't see the architecture. **How to avoid:** - The clock is an injected parameter — sim functions take `(state, dt, now)` not `(state, dt)`. - Add an ESLint rule: `'no-restricted-syntax': ['error', { selector: "CallExpression[callee.object.name='Date'][callee.property.name='now']", message: 'src/sim/** must inject time; only src/sim/scheduler/clock.ts may read Date.now()' }]` scoped to `src/sim/**` excluding `src/sim/scheduler/clock.ts`. - Add a Vitest test that runs through the sim with a FakeClock and asserts no real-time elapses (i.e., real-`Date.now()` and the result are unchanged). **Warning signs:** A test passes only when run quickly; a Playwright fast-forward changes results. ### Pitfall 2: 4×4 garden state confusion (tile coords vs index) **What goes wrong:** Tile is at `(row=2, col=3)`. Code uses `index = row * 4 + col` in some places and `index = col * 4 + row` in others. Save loads the wrong tile. **How to avoid:** Pick one canonical encoding (`index = row * 4 + col`) in `src/sim/garden/types.ts` with an exported helper `tileIdx(row, col)` and `tileCoords(idx)`. Ban raw arithmetic at call sites. **Warning signs:** Plant appears in wrong tile after refresh; save round-trip test fails on tile order. ### Pitfall 3: Begin-screen skip logic misclassifies returning players **What goes wrong:** D-22 says "first run = no save exists". An IndexedDB read fails (private mode, blocked) → save layer falls through to LocalStorage → LocalStorage read returns `null` (genuinely missing) vs `null` (silently failed). Returning player sees Begin screen again. **How to avoid:** `openSaveDB()` already wraps the IDB-or-LocalStorage decision; the SaveDB interface exposes both. Phase 2's "first run?" check is `(await db.get('saves', 'main')) === undefined`. The SaveDB abstraction makes IDB-vs-LS transparent. Test both paths in Vitest. **Warning signs:** Returning player on iOS Safari sees Begin screen on every launch. ### Pitfall 4: Ink variable casing mismatch (case-sensitive) **What goes wrong:** Ink declares `VAR plants_bloomed = 0`. JS sets `story.variablesState['plantsBloomed'] = 5`. No error fires; the variable stays at 0; the letter renders the wrong branch. **How to avoid:** Use `snake_case` for all Ink variables. Centralize the mapping table in `src/ui/letter/letter-renderer.ts`: ```typescript const INK_VARIABLE_MAP = { plants_bloomed: (s: AppStoreShape) => s.offlineEvents?.plantsBloomedCount ?? 0, fragment_titles: (s: AppStoreShape) => s.offlineEvents?.harvestedFragmentTitles.join(', ') ?? '', lura_was_here: (s: AppStoreShape) => s.offlineEvents?.luraBeatPending ?? false, } as const; ``` A test asserts every key in `INK_VARIABLE_MAP` exists in the compiled `.ink.json` (parsed for `VAR` declarations). **Warning signs:** Letter renders an unexpected branch; "fragment_titles" shows literal text. ### Pitfall 5: AudioContext spec disagreement on `resume()` timing **What goes wrong:** Mobile Safari requires `AudioContext` to be *created* synchronously inside the gesture handler — not just `resumed` inside it. Creating in `useEffect` and calling `resume()` in click fails on iOS. **How to avoid:** Lazy-create the `AudioContext` inside `bootstrapAudioContext()` which is called from the click handler. Don't pre-create on mount. **Warning signs:** Audio works on Chrome desktop, fails silently on iOS. [CITED: developer.mozilla.org/Web/Media/Guides/Autoplay — autoplay policy details for cross-browser behavior.] ### Pitfall 6: Phaser scene stale closure over Zustand state **What goes wrong:** Phaser scene's `create()` does `const tiles = appStore.getState().tiles` once; later updates don't propagate to the scene's render. Plants never appear after planting. **How to avoid:** Inside Phaser scenes, *subscribe* to the store: `appStore.subscribe(state => this.renderTiles(state.tiles))`. Or — simpler for Phase 2 — re-read `appStore.getState()` inside the scene's `update()` loop (it runs at 60Hz; reading is cheap). The latter is the minimum-viable choice. **Warning signs:** Plant placed via React popover does not appear in canvas until refresh. ### Pitfall 7: Save fires AFTER React unmounts on `beforeunload` **What goes wrong:** `beforeunload` listener calls `await saveAsync()`. Browser doesn't await async work in unload handlers. Save races and may not flush. **How to avoid:** Use a synchronous-ish save path on `beforeunload`: serialize the envelope (synchronous), write to LocalStorage (synchronous), and *only* attempt IndexedDB best-effort. The next-load path checks both stores; whichever has the more recent `lastTickAt` wins. **Warning signs:** Closing the tab and reopening loses 1–10 seconds of progress. ### Pitfall 8: Fragment selector returns duplicates after exhaustion **What goes wrong:** MEMR-06 says "no duplicates within a single playthrough until the pool is exhausted." Once exhausted, the spec is silent. If the selector throws, harvests after exhaustion break. **How to avoid:** When all fragments in the gated pool have been harvested, the selector falls back to a "memory-of-a-memory" pool (a single fragment authored as the exhaustion marker, e.g., `season1.soil.gardener-knows-this-one-already`) OR repeats the most recently harvested one (least bad option for UX). Phase 2 should ship enough Season-1 fragments that exhaustion is unlikely (target ≥10 to comfortably exceed the 8th-harvest Lura threshold + plant-type unlocks). Document the chosen behavior in PLAN.md. **Warning signs:** Player reaches a state where harvest does nothing; or duplicate fragments appear unexpectedly. ### Pitfall 9: Letter overlay swallows tap-to-resume gesture **What goes wrong:** Player returns after >5min. Letter overlay mounts. Player taps to dismiss → letter dismisses but `AudioContext.resume()` is not called because no `bootstrapAudioContext()` handler is registered on the letter's dismiss button. **How to avoid:** The letter's dismiss button calls `bootstrapAudioContext()` exactly like the Begin button. Same for any first-interaction surface a returning player might hit before the live garden. **Warning signs:** Audio fails for returning players who land directly in the letter. ### Pitfall 10: Plant-type unlock thresholds vs fragment-count off-by-one **What goes wrong:** D-05 says "remaining 1–2 unlock by fragment-count thresholds." Player has 5 harvests. Threshold for plant 2 is `5`. Off-by-one bug: unlock fires *before* the 5th harvest is committed to the store. **How to avoid:** Always check thresholds *after* the harvest commit, in the same simulate-step's post-step hook. Test the boundary (4 harvests = locked, 5 harvests = unlocked) explicitly. **Warning signs:** Race condition between unlock and reveal modal. ## Code Examples ### Verified pattern: Phaser scene → React EventBus signaling scene-ready ```typescript // Source: phaser.io/news/2025/05/bringing-our-phaserjs-templates-into-the-future // src/game/scenes/Garden.ts import * as Phaser from 'phaser'; import { eventBus } from '../event-bus'; export class Garden extends Phaser.Scene { constructor() { super('Garden'); } create(): void { // ... build tile objects, plant primitives, gate ... eventBus.emit('scene-ready', this); } update(_time: number, _delta: number): void { // Re-read from store on each frame; render. // The scheduler is invoked on a fixed cadence inside this loop — // see src/sim/scheduler/tick.ts for the accumulator drain. } } ``` ### Verified pattern: Zustand 5 vanilla store with React hook ```typescript // Source: zustand.docs.pmnd.rs/reference/apis/create-store // src/store/store.ts import { createStore } from 'zustand/vanilla'; import { useStore } from 'zustand'; export const appStore = createStore()((set) => ({ // … slice spreads })); // React side export const useAppStore = (selector: (s: AppStoreShape) => T): T => useStore(appStore, selector); ``` ### Verified pattern: Tick scheduler accumulator (idle-game canonical) ```typescript // Source: gafferongames.com/post/fix_your_timestep + .planning/research/ARCHITECTURE.md Pattern 2 // src/sim/scheduler/tick.ts (excerpt — full version above) const TICK_MS = 200; const MAX_OFFLINE_MS = 24 * 3600 * 1000; // drainTicks refuses negative dt (CORE-11) and clamps offline to 24h (CORE-03). ``` ### Verified pattern: inkjs Story instantiation ```typescript // Source: github.com/y-lohse/inkjs README; node_modules/inkjs/ink.d.mts // src/ui/dialogue/ink-runtime.ts (excerpt) import { Story } from 'inkjs'; const story = new Story(compiledJson); story.variablesState['fragment_count'] = snapshot.harvestedFragmentIds.length; const line = story.Continue(); ``` ## State of the Art | Old Approach | Current Approach (2026) | When Changed | Impact | |--------------|-------------------------|--------------|--------| | `setInterval` for idle tick | Elapsed-time-based progression with `requestAnimationFrame`-driven scheduler | Chrome 88+ background-tab timer throttling (Jan 2021) | Mandatory for any idle game; `.planning/research/PITFALLS.md` #12. | | Phaser 3 with custom React shell | Phaser 4 official `template-react-ts` with `EventBus` singleton | Phaser 4 release (April 2026) + template refresh (May 2025 announcement) | Removes ~6 months of custom integration; what `src/PhaserGame.tsx` already implements. | | Zustand 4 with React-only API | Zustand 5 with `zustand/vanilla` `createStore` | Zustand 5 release | Lets sim-side adapters and Phaser scenes participate without React-fiber overhead. | | Ink with hand-rolled drip rendering | `inkjs` v2.4 + ESM `import { Story } from 'inkjs'` | inkjs 2.x ESM exports landed | TypeScript-friendly imports; compiled output stays portable. | | `@types/howler` always required | Howler.js 2.2.4 ships compatible types via `@types/howler` (DefinitelyTyped) | — | Phase 3 concern. | **Deprecated/outdated (avoid):** - `setInterval`-driven game logic (Pitfall 12). - Storing entitlements client-side (Pitfall 4 in `.planning/research/ARCHITECTURE.md`'s Anti-Patterns) — moot for Phase 2 (no entitlements yet, but worth knowing for v2). - `process.env.NODE_ENV` in browser code — Vite uses `import.meta.env` (already used by Phase 1). - `phaser/types/Phaser` deep imports — Phaser 4 exports types from the package root. ## Assumptions Log | # | Claim | Section | Risk if Wrong | |---|-------|---------|---------------| | A1 | The Phaser 4 `EventBus` pattern (Phaser.Events.EventEmitter singleton) is the documented Phaser-React integration in Phaser 4's official template, just as it was in Phaser 3's template. | Pattern 3 + System Architecture Diagram | LOW — verified via [phaser.io/news/2025/05/bringing-our-phaserjs-templates-into-the-future]; the template's EventBus pattern explicitly says the May 2025 update prepared the template for Phaser 4. If Phaser 4's release shifted this, Phase 2 falls back to `mitt` which is already research-confirmed in `.planning/research/STACK.md`. | | A2 | A 5Hz sim tick rate (TICK_MS=200) is appropriate for 2–5 minute plant growth. | Pattern 1 | LOW — derived from `.planning/research/ARCHITECTURE.md` Pattern 2's worked example. If playtest shows visible stepping in growth animation, Phase 2 can bump to 10Hz (TICK_MS=100) without changing any sim code (only the constant). | | A3 | A 24h offline catch-up at 5Hz (=432K ticks) catches up in well under 1s on a modern device. | Pattern 1 | LOW — the per-tick work is small (state mutation + accumulator drain); but if the simulate function ends up doing string allocations per tick, this could degrade. Vitest benchmark in scheduler tests should assert ≤500ms for 432K ticks. | | A4 | Ink variables (`VAR plants_bloomed = 0`) set via `story.variablesState['plants_bloomed'] = N` work for letter templating in inkjs 2.4.0. | Pattern 6 | LOW — verified via [github.com/y-lohse/inkjs README "Differences with the C# API: Getting and setting ink variables"]; this is the documented mechanism. | | A5 | Phaser pointer event coordinates (`pointer.x`, `pointer.y`) are in CSS-pixel space and can be used directly as DOM `position: absolute` coords for the seed picker without `getBoundingClientRect` adjustments. | Pattern 4 | MEDIUM — depends on the canvas's CSS layout. Phase 1's scaffold uses `Phaser.Scale.FIT`, which adds letterboxing. The popover may need to `getBoundingClientRect` the `#game-container` and add its `left/top` offset. Plan 2 should test on a non-fullscreen window. | | A6 | `inklecate` (the npm wrapper around the .NET binary) runs cleanly on Windows + macOS + Linux CI. | Pattern 5 | MEDIUM — `inklecate.exe` ships in `node_modules/inklecate/bin/`; the wrapper picks the right binary per platform. Phase 2's `compile-ink.mjs` script must not assume a specific binary path; it should call the `inklecate` package's exported function. The package was installed in Phase 1 but never invoked beyond the no-op stub — Plan 2 should confirm with one real compile on Windows-the-dev-machine before authoring all Lura content. | | A7 | Phase 2's letter rendering uses Ink variable substitution + conditional branches, not `EXTERNAL` functions. | Pattern 6 | LOW — variable substitution is sufficient for D-19's slot vocabulary; flagged here so reviewers know we explicitly chose simpler over more powerful. | | A8 | Authoring "≥10 Season-1 fragments" is sufficient to satisfy 1st/4th/8th Lura thresholds + 1–2 plant-type unlocks + buffer. | Pitfall 8 | LOW — covers the worst case (8 harvests + 1 plant-type unlock margin). User reviews specific count during planning. | | A9 | The first-interaction gesture handler installed on returning-player loads (Pattern 9) is reliable across browsers (no edge case where audio fails silently). | Pattern 9 | LOW — the `click`+`touchstart`+`keydown` triple-listener is the documented pattern. iOS quirks were the historical bug; modern Safari respects `resume()` after any user gesture. Phase 8 e2e test on iOS Safari is the right place to verify. | | A10 | Per-Season lazy loading via `import.meta.glob({ eager: false })` produces real chunk splits for Phase 2 even with only one Season authored. | Pattern 8 | LOW — Vite always splits each lazy import; the chunk happens to be a single file in Phase 2, but the wiring is correct for Phase 4+. Verify with a `npm run build && ls dist/assets/` showing a `season1`-named chunk. | **If this table is empty:** All claims in this research were verified or cited — no user confirmation needed. This table has 10 entries; all are `LOW` risk except A5 and A6 (`MEDIUM`). Plan 2 should explicitly verify A5 (canvas-to-DOM coord mapping under FIT scale) and A6 (inklecate wrapper invocation) early. ## Open Questions (RESOLVED) > All five open questions resolved during /gsd-discuss-phase 2 + planning. Resolutions are codified in CONTEXT.md decisions and the per-plan PLANs. Recommendations below are ratified, not pending. 1. **Plant-type identity per the 2–3 plant types in Season 1 (per D-03 + D-09).** - What we know: 2–3 plants, varying durations within 2–5min, distinct fragment pools, distinct visual primitive (different tint per plant), tonal identity per plant. First plant from start; remaining unlock at fragment-count thresholds. - What's unclear: the actual identities (e.g., "rosemary vs. yarrow vs. winter-rose"?). Bible says "real flora, slightly wrong" but Phase 2 doesn't need painted flora — names matter for fragment authoring. - RESOLVED: Plan 2's content authoring step proposes 3 names tied to 3 fragment-pool tonal registers (warm / contemplative / heavy). Locked names: rosemary (warm), yarrow (contemplative), winter-rose (heavy). See Plan 02-02 PLANT_TYPES. 2. **Compost UX shape (GARD-04 "tonal beat acknowledging the choice to let go").** - What we know: composting is a Phase 2 mechanic; tonal beat must "acknowledge the choice to let go". Bible voice is warm + specific. - What's unclear: is the beat a small text snippet, a particle effect, a sound? D-07 leaves form to Claude's discretion *for harvest*; GARD-04 is the *compost* analog and isn't in CONTEXT. - RESOLVED: a single Ink-authored line per compost (`content/dialogue/season1/compost-acknowledgements.ink`, ~3–5 short lines, randomly selected). Authored in Plan 02-04. UI-wiring deferral acknowledged in 02-04 SUMMARY → 02-05 toast surface. 3. **Save trigger on Season transitions (UX-10).** - What we know: UX-10 specifies "saves on visibilitychange to hidden, on beforeunload, AND on Season transitions." - What's unclear: Phase 2 has no Season transitions (Season 1 only). The save-on-transition hook is dormant until Phase 4. - RESOLVED: implement the save hook as a pure function callable by future-phase code (`saveOnSeasonTransition(state)` exported from `src/save/index.ts`). Phase 2 verifies it via unit test only; first real call is Phase 4. See Plan 02-01 src/save/lifecycle.ts. 4. **PIPE-02 lazy-loading scope when only Season 1 is authored.** - What we know: PIPE-02 says "future Seasons are not in the initial bundle." - What's unclear: with only Season 1 authored, the lazy split is a wiring exercise — Phase 2's actual *initial* bundle contains everything because there's nothing else to lazy-load. Is the requirement satisfied "vacuously" (no Seasons 2-7 exist), or does the pattern need to demonstrably work? - RESOLVED: ship the lazy pattern (`{ eager: false }`) so Phase 4 inherits it without rework. Plan 02-03 ships scripts/check-bundle-split.mjs; PIPE-02 verification is structural via that script + a Vitest case. PIPE-07 e2e covers the runtime loop. 5. **Should the offline letter ALSO surface the just-unlocked Lura beat indicator, or are they decoupled?** - What we know: D-19 says `offlineEvents` includes "a flag for any newly-unlocked Lura beat queued for first-visit." D-15 says the beat-fire UX is "a soft cue at the gate." D-20 says the letter is full-screen. - What's unclear: when a beat unlocks during absence, does the letter mention Lura? Or does the player dismiss the letter into the live garden where the gate glows? - RESOLVED: the letter mentions Lura's presence in voice (Ink slot `lura_was_here`), the gate also indicates the queued beat. Both surfaces, complementing each other. See Plan 02-05 letter-from-the-garden.ink + Plan 02-04 gate-renderer.ts. ## Environment Availability | Dependency | Required By | Available | Version | Fallback | |------------|------------|-----------|---------|----------| | Node.js | Build, test, CI | ✓ | (Phase 1 ran on Node 22 per `.github/workflows/ci.yml`) | — | | `npm` | Install + scripts | ✓ | (bundled with Node) | — | | Phaser 4 | Canvas rendering | ✓ | 4.1.0 | — | | React 19 | UI overlays | ✓ | 19.2.6 | — | | Vite | Dev server + bundler | ✓ | 8.0.11 | — | | TypeScript | Source compilation | ✓ | 6.0.3 | — | | Vitest | Unit + integration tests | ✓ | 4.1.5 | — | | Playwright | E2E smoke | ✓ | 1.59.1 | — | | `idb` | Save layer | ✓ | 8.0.3 | LocalStorage adapter (already shipped in `src/save/`) | | `lz-string` | Save compression | ✓ | 1.5.0 | — | | `crc-32` | Save checksum | ✓ | 1.2.2 | — | | `zod` | Schema validation | ✓ | 4.4.3 | — | | `gray-matter` | Markdown frontmatter | ✓ | 4.0.3 | — | | `yaml` | YAML parsing | ✓ | 2.8.4 | — | | `inkjs` | Ink runtime | ✓ | 2.4.0 | — | | `inklecate` | Ink build-time compilation | ✓ | 1.8.1 (binaries shipped per platform) | — | | `happy-dom` | Vitest DOM env | ✓ | 20.9.0 | — | | `fake-indexeddb` | IDB tests | ✓ | 6.2.5 | — | | `eslint` + `eslint-plugin-boundaries` | CORE-10 firewall | ✓ | 9.39.4 + 6.0.2 | — | | **`zustand`** | State bridge (D-32) | ✗ | — | **Install in Phase 2** (`npm install zustand@^5.0.0`) | | **`break_eternity.js`** | BigQty wrapper (D-31) | ✗ | — | **Install in Phase 2** (`npm install break_eternity.js@^2.1.3`) | | `howler` | Audio (Phase 3 only) | ✗ | — | Phase 2 stubs `AudioContext.resume()` only (D-21); Phase 3 installs and wires Howler. | | AudioContext API | Begin gesture (AEST-07) | ✓ (browser) | All evergreen + iOS Safari | None — handled gracefully in `bootstrapAudioContext()` (returns `null`). | | `navigator.storage.persist()` API | CORE-05 (already shipped) | ✓ in modern browsers; ✗ in iOS Safari | — | Phase 1's `requestPersistence()` returns `apiAvailable: false` and the toast doesn't fire. | | IndexedDB | Save primary (already shipped) | ✓ in all browsers; ✗ in private mode (some) | — | Phase 1's `LocalStorageDBAdapter` fallback. | **Missing dependencies with no fallback:** None. **Missing dependencies with fallback:** None blocking — Howler.js is intentionally Phase-3 (D-21). ## Validation Architecture ### Test Framework | Property | Value | |----------|-------| | Framework | Vitest 4.1.5 + Playwright 1.59.1 | | Config files | `vitest.config.ts` (env: happy-dom; pre-existing) + `playwright.config.ts` (pre-existing — first specs land Phase 2) | | Quick run command | `npm test` (= `vitest run --passWithNoTests=false`) | | Full suite command | `npm run ci` (= `lint + test + validate:assets + build`) | | E2E run command | `npx playwright test` | ### Phase Requirements → Test Map | Req ID | Behavior | Test Type | Automated Command | File Exists? | |--------|----------|-----------|-------------------|-------------| | CORE-02 | Sim runs deterministic fixed-timestep loop, advancing by elapsed real time | unit | `vitest run src/sim/scheduler/tick.test.ts` | Wave 0 | | CORE-03 | Closing tab + returning advances by elapsed time, capped at 24h | unit | `vitest run src/sim/scheduler/catchup.test.ts` | Wave 0 | | CORE-11 | Sim refuses negative deltas + clamps offline at 24h | unit | `vitest run src/sim/scheduler/catchup.test.ts -t 'refuses negative'` | Wave 0 | | CORE-11 | Sim refuses negative deltas (integration with FakeClock) | integration | `vitest run src/sim/scheduler/clock.test.ts` | Wave 0 | | GARD-01 | Plant a seed into unoccupied tile | unit | `vitest run src/sim/garden/commands.test.ts -t 'plantSeed'` | Wave 1A | | GARD-01 | Click empty tile → seed picker appears at correct screen coords (DOM) | integration | `vitest run src/ui/garden/SeedPicker.test.tsx` | Wave 1A | | GARD-02 | Plant advances sprout → mature → ready over time | unit | `vitest run src/sim/garden/growth.test.ts` | Wave 1A | | GARD-02 | Growth state survives save round-trip + load | integration | `vitest run src/save/round-trip.test.ts -t 'plant growth state'` | Wave 1A (extends existing test) | | GARD-03 | Harvest mature plant yields exactly one fragment, empties tile | unit | `vitest run src/sim/garden/commands.test.ts -t 'harvest'` | Wave 1B | | GARD-04 | Compost immature plant + tonal beat | unit + UI | `vitest run src/sim/garden/commands.test.ts -t 'compost'` + `src/ui/garden/CompostBeat.test.tsx` | Wave 1B | | MEMR-01 | Each harvest yields exactly one fragment from authored pool gated by Season + progression | unit | `vitest run src/sim/memory/selector.test.ts -t 'one fragment per harvest'` | Wave 1B | | MEMR-02 | Fragments authored in `/content/seasons/01-soil/`, compiled at build | manual + CI | `npm run build` succeeds with Season-1 content present; loader test green | Wave 1B (extends `src/content/loader.test.ts`) | | MEMR-03 | Stable string IDs (regex enforced) | unit | `vitest run src/content/loader.test.ts -t 'rejects numeric id'` (existing) | ✅ | | MEMR-04 | Memory Journal lists every collected fragment, organized by Season | integration | `vitest run src/ui/journal/Journal.test.tsx` | Wave 1B | | MEMR-05 | Player can read fragment in full; text is selectable + copy-pasteable (DOM, not canvas) | integration | `vitest run src/ui/journal/Journal.test.tsx -t 'selectable text'` | Wave 1B | | MEMR-06 | Selector deterministic; respects gating; no duplicates within playthrough until exhausted | unit | `vitest run src/sim/memory/selector.test.ts -t 'no duplicates' + 'gating' + 'deterministic'` | Wave 1B | | STRY-01 | Lura appears at gate during Season 1 with text-message-cadence dialogue authored in Ink | integration + e2e | `vitest run src/ui/dialogue/LuraDialogue.test.tsx` + Playwright e2e | Wave 2A | | STRY-06 | All dialogue is `.ink` compiled to JSON | manual + CI | `npm run compile:ink` produces `src/content/compiled-ink/season1/lura-*.ink.json` | Wave 2A | | STRY-07 | Keeper has no dialogue beyond final binary choice (Phase 7) | manual | Vacuously true; document in PLAN.md verification | ✅ (vacuous) | | STRY-10 | Story progression gates on tick count, not wall time | unit | `vitest run src/sim/narrative/lura-gate.test.ts -t 'gates on harvest count'` + clock-cheat test (FakeClock advance ≠ beat advance) | Wave 2A | | AEST-07 | First screen is "Begin" gesture gate that calls `AudioContext.resume()` | integration + e2e | `vitest run src/ui/begin/BeginScreen.test.tsx -t 'resume on tap'` + Playwright e2e | Wave 1A | | UX-01 | Single Begin screen with no UI clutter; UI grows progressively | manual + integration | A Dark Room rule manual verification + journal-icon-hidden-pre-harvest test | Wave 1A + Wave 1B | | UX-02 | Returning-player letter from the garden, in voice | integration | `vitest run src/ui/letter/Letter.test.tsx` | Wave 2B | | UX-10 | Save on `visibilitychange` hidden, on `beforeunload`, on Season transitions | integration | `vitest run src/save/lifecycle.test.ts` (NEW) | Wave 0 (or extends existing round-trip) | | UX-11 | Numbers display as 1.2K / 4.5M / scientific past threshold | unit | `vitest run src/sim/numbers/format.test.ts` | Wave 0 | | PIPE-02 | Initial bundle excludes Seasons 2-7; per-Season lazy chunks | structural | `npm run build` + filesystem assertion (new `scripts/check-bundle-split.mjs`) | Wave 1B | | PIPE-07 | E2E smoke: load → begin → plant → fast-forward → harvest → journal-shows-fragment → reload → fragment-persists | e2e | `npx playwright test tests/e2e/season1-loop.spec.ts` | Wave 2C | ### Sampling Rate - **Per task commit:** `npm test` (Vitest unit + integration only; Playwright excluded for speed; current p50 ≈ ~5s based on Phase 1's 53-tests-in-12-files rate) - **Per wave merge:** `npm run ci` (lint + test + validate:assets + build); manual `npx playwright test` for Wave 2C - **Phase gate:** `npm run ci` green AND `npx playwright test` green before `/gsd-verify-work` ### Wave 0 Gaps Wave 0 (the foundations plan) is the only plan that adds testing infrastructure beyond what Phase 1 shipped: - [ ] `src/sim/scheduler/clock.test.ts` — FakeClock fixture + monotonic invariants - [ ] `src/sim/scheduler/tick.test.ts` — accumulator drain math + tick rate - [ ] `src/sim/scheduler/catchup.test.ts` — 24h cap + negative-delta refusal - [ ] `src/sim/numbers/big-qty.test.ts` — arithmetic + comparison + serialization round-trip - [ ] `src/sim/numbers/format.test.ts` — UX-11 thresholds (1.2K, 4.5M, etc.) - [ ] `src/save/lifecycle.test.ts` — visibilitychange + beforeunload + Season transition save firing (UX-10) - [ ] `src/store/store.test.ts` — slice composition + selectors - [ ] `src/store/garden-slice.test.ts` — command queueing + apply-result semantics Wave 1A (planting + growth + Begin) gaps: - [ ] `src/sim/garden/commands.test.ts` — plantSeed, harvest, compost (pure functions) - [ ] `src/sim/garden/growth.test.ts` — state machine - [ ] `src/sim/garden/auto-harvest.test.ts` — D-10 offline branch - [ ] `src/ui/begin/BeginScreen.test.tsx` — tap calls bootstrapAudioContext - [ ] `src/ui/garden/SeedPicker.test.tsx` — popover positioning + dismiss Wave 1B (memory + journal + fragments + content) gaps: - [ ] `src/sim/memory/selector.test.ts` — deterministic + gating + no-dup + exhaustion - [ ] `src/ui/journal/Journal.test.tsx` — list + select + copy - [ ] `src/ui/journal/FragmentRevealModal.test.tsx` — D-25 reveal flow - [ ] `src/content/loader.test.ts` extension — Season-1 fragments load via lazy path - [ ] `scripts/check-bundle-split.mjs` — PIPE-02 structural test Wave 2A (Lura beats) gaps: - [ ] `src/sim/narrative/lura-gate.test.ts` — 1st/4th/8th gating + STRY-10 tick-count semantics - [ ] `src/ui/dialogue/LuraDialogue.test.tsx` — Ink runtime integration + drip cadence - [ ] `src/ui/dialogue/ink-runtime.test.ts` — variable wiring Wave 2B (offline letter) gaps: - [ ] `src/sim/offline/events.test.ts` — `OfflineEventBlock` schema + aggregator - [ ] `src/ui/letter/Letter.test.tsx` — Ink template render + dismiss - [ ] `src/ui/letter/letter-renderer.test.ts` — variable mapping table coverage Wave 2C (e2e) gaps: - [ ] `tests/e2e/season1-loop.spec.ts` — full loop smoke - [ ] Playwright fast-forward mechanism (URL flag `?devtime=fake`, recommended) — implementation in `src/sim/scheduler/clock.ts` + boot wiring **No framework install needed** — Vitest, Playwright, fake-indexeddb, happy-dom are all already on disk from Phase 1. ### Sim-Clock Injection for Playwright Fast-Forward The Phase 2 e2e (PIPE-07) needs to fast-forward growth without waiting 2–5 minutes per plant. Three viable mechanisms: | Mechanism | How | Pros | Cons | |-----------|-----|------|------| | **URL flag (`?devtime=fake`) + FakeClock injection** ⭐ | Boot reads `URLSearchParams`; if `devtime=fake`, the scheduler binds to a `FakeClock` instance exposed on `window.__tlgFakeClock`. Playwright calls `await page.evaluate(() => window.__tlgFakeClock.advance(5 * 60 * 1000))` to fast-forward 5 minutes. | Cleanly reusable in Vitest tests via the same FakeClock; no production code path differs; cleanly excluded from prod builds via build-time check. | URL flag must be respected only in dev/test contexts (Vite's `import.meta.env.DEV`). | | Hidden dev hotkey | `?+fast` keyboard combo advances FakeClock | Useful for manual playtest. | Doesn't compose with Playwright; still need the URL flag. | | Sim-tick-count manipulation | E2E test directly mutates `appStore.getState().lastTickAt` | Avoids the FakeClock entirely. | Bypasses the whole scheduler boundary; risk of false-positive test results if scheduler has bugs. | **Recommendation:** Implement the URL flag + FakeClock injection (option ⭐). The FakeClock already exists in `src/sim/scheduler/clock.ts` for Vitest tests; the URL flag merely chooses the production wallClock vs the dev FakeClock at boot. Phase 2's PIPE-07 spec ships with this mechanism. Add a guard: in `import.meta.env.PROD` builds, the URL flag is silently ignored (the wallClock is always used). ## Security Domain `security_enforcement` is not explicitly false in `.planning/config.json`, so this section is included. ### Applicable ASVS Categories | ASVS Category | Applies | Standard Control | |---------------|---------|-----------------| | V2 Authentication | no | No accounts in v1 (single-player local). | | V3 Session Management | no | Same. | | V4 Access Control | no | Same. | | V5 Input Validation | yes | All save imports go through `SaveEnvelopeSchema` (Zod) and the 50MB cap (already shipped Phase 1). All Ink-runtime variable injection is type-checked at the JS boundary; no string concatenation into Ink source at runtime. | | V6 Cryptography | partial (integrity only) | CRC-32 checksum on save envelopes (already shipped Phase 1) detects corruption, NOT adversarial tampering. Phase 2 inherits this policy — single-player save tampering is by-design acceptable per `src/save/envelope.ts` `SaveCorruptError` doc comment. | | V7 Error Handling and Logging | partial | Toast surfaces persistence-result respectfully (D-30); no PII in logs (none collected in v1). | | V8 Data Protection | yes | No telemetry in Phase 2; saves are local-only; Base64 export is user-initiated and contains no secrets. | | V12 Files and Resources | yes | `validate-assets.mjs` already shipped Phase 1; no new asset surfaces in Phase 2 (D-26 = primitives only). | ### Known Threat Patterns for Phase 2 Stack | Pattern | STRIDE | Standard Mitigation | |---------|--------|---------------------| | Save tampering (player edits Roothold via DevTools) | Tampering | **Accepted** — single-player; CRC-32 detects accidental corruption only. Documented in `src/save/envelope.ts` (already Phase 1). | | Malformed Base64 import (DoS via giant inflated string) | Denial-of-service | 50MB cap before lz-string decompression (already Phase 1). | | System-clock manipulation to skip Lura beats | Tampering | Beats gate on tick count (harvest events), not wall time (STRY-10). FakeClock advance ≠ beat advance unless harvests also fire. | | `Date.now()` returns negative delta (clock-rewind cheat) | Tampering | Scheduler refuses negative deltas (CORE-11); state does not advance; logged once. | | AudioContext blocked → muted experience that misleads about audio failures | Information disclosure (sort of) / UX | Bootstrap function returns `null` gracefully; Phase 2 has no audio anyway. | | Cross-origin script injection via Ink content | XSS | Ink content is repo-controlled (no user-authored Ink); inkjs renders to string and React renders strings, not HTML. No `dangerouslySetInnerHTML`. | | Storage eviction silently wiping save | Tampering / data loss | `navigator.storage.persist()` request (CORE-05, already Phase 1) + LocalStorage fallback + Base64 export; soft toast respects user agency (D-30). | ## Project Constraints (from CLAUDE.md) These directives have the same authority as locked decisions and constrain Phase 2 plans: - **Stack is locked, do not re-litigate:** Phaser 4, React 19, Zustand 5, break_eternity.js (via BigQty), Ink + inkjs, Howler.js, IndexedDB + LocalStorage + lz-string + versioned schema + Base64 export, Markdown+frontmatter / YAML / .ink in `/content/`, Vitest + Playwright. Phase 2 plans must use these; alternatives are out of scope unless explicitly requested. - **Architectural firewall (load-bearing):** Phaser owns the canvas; React 19 owns the UI shell; Zustand bridges them; simulation core (`src/sim/`) does not import from `src/render/` or `src/ui/`. Enforced by `eslint.config.js` `boundaries/element-types` rule. Plans that introduce sim→render or sim→ui edges fail CI. - **TypeScript strict; no `any` in production code.** Phase 2 sim code is fully typed; Zod schemas produce inferred types throughout. - **Player-visible strings are externalized in `/content/`, never hardcoded.** Lura's lines, the letter, the Begin copy, the post-harvest acknowledgement, the persistence-denied toast, the journal empty-state — all in `/content/`. - **Memory fragment IDs are stable strings (`season3.canopy.lura_07.vignette`), never numeric.** Already enforced by `FragmentSchema` regex. - **Simulation modules are pure** — no `Date.now()`, no `setInterval`, no DOM, no fetch. **Inject time as a parameter; the tick scheduler owns wall-clock access.** This is exactly D-33 + Pattern 1. - **BigNumbers go through the typed `BigQty` wrapper around `break_eternity.js`. Never raw `Decimal` values in app code.** This is exactly D-31 + Pattern 2. - **Save format always carries `{schemaVersion, payload, checksum}`. Never serialize raw state.** Already shipped by `src/save/envelope.ts` Phase 1. - **New AI-generated assets must carry full provenance metadata and pass the curation gate.** Phase 2 ships zero AI-generated assets (D-26 = primitives), so the gate is not exercised; remains green. - **Anti-FOMO doctrine** must be consulted at every UX decision. Phase 2's UX decisions all comply: no daily login bonus, no streaks, no limited-time content, no nag notifications, no loss-aversion copy, no countdown timers in core UI, persistence-denied is a soft in-voice toast (not nag). - **Banner concerns 1–10** (CLAUDE.md): all carry forward. Banner concern #1 (story ends but loop doesn't) is constrained by Phase 2's growth/economy choices not foreclosing Season 7's finite ceiling — confirmed by Pattern 7 (`V1Payload` extension, no Roothold pre-allocation; Phase 4 owns the prestige machinery). - **Tone:** Player-facing copy "warm, specific, intermittent, sometimes funny, sometimes devastating." Lura is the warmth anchor — write her as the contrast, not a co-griever. - **GSD config:** Mode=YOLO (auto-approve gates), Granularity=Standard (5–8 phases), Plans run in parallel within a phase, Quality model profile, research+plan-check+verifier all on, `nyquist_validation: true`. Plans use the Validation Architecture section above. ## MVP Slice Proposal Phase 2's mode is `mvp` (vertical-slice planning). Each plan should deliver an end-to-end thin slice rather than a horizontal layer. Recommended **5 plans across 3 waves**: ### Wave 0: Foundations (1 plan, blocks everything else) **Plan 02-01 — Foundations: BigQty + Zustand store + Tick scheduler** - Install `zustand@^5` and `break_eternity.js@^2.1.3`. - Author `src/sim/numbers/big-qty.ts` + `format.ts` with full Vitest coverage (UX-11 surface). - Author `src/sim/scheduler/clock.ts`, `tick.ts`, `catchup.ts` with Vitest coverage (CORE-02, CORE-03, CORE-11). Add the no-`Date.now`-in-sim ESLint rule. - Author `src/store/store.ts` + four slices (garden, memory, narrative, session) with Vitest tests of slice composition + selectors. - Extend `V1Payload` per D-34 (D-19's `offlineEvents`, `unlockedPlantTypes`, `luraBeatProgress`, `settings.persistenceToastShown`). - Add `src/save/lifecycle.test.ts` covering UX-10 trigger points. - Wire the scheduler into `Garden` scene (tiny placeholder scene) so the live tick boots end-to-end. - Adds `src/game/event-bus.ts` for the Phaser EventBus singleton (per Phaser 4 template pattern). - Verifies `npm run ci` green. **Output: A running scaffold where the sim ticks, the store updates, the save schema is extended, and the firewall holds.** Nothing player-visible yet — but Wave 1 plans can build features without re-running Wave 0's infrastructure. ### Wave 1: Two parallel vertical slices (2 plans) **Plan 02-02 — Vertical slice: Begin + Plant + Grow** - Authors Season-1 plant types (2–3) in `src/sim/garden/plants.ts` with durations + tonal identity. - Implements `src/sim/garden/{types,growth,commands}.ts` (plantSeed + grow). - Implements `src/render/garden/{tile-renderer,plant-renderer,ready-pulse,tile-coords}.ts`. - Implements `src/ui/begin/{BeginScreen,use-audio-bootstrap}.ts` (D-21, D-22, AEST-07). - Implements `src/ui/garden/SeedPicker.tsx` (D-02 inline popover). - Wires Phaser's tile pointerdown → EventBus → React popover → command dispatch. - Implements first-interaction gesture handler for returning players (D-22). - Tests: vitest unit + integration (Begin tap calls resume; tile click positions popover; plant grows through stages). - E2E manual smoke: launch → press Begin → click empty tile → seed picker → place seed → watch sprout primitive appear → fast-forward via FakeClock. - Satisfies: AEST-07, UX-01, GARD-01, GARD-02 (partial — full save round-trip in Plan 02-04). **Plan 02-03 — Vertical slice: Harvest + Journal + Compost + Fragments** - Author Season-1 fragments (≥10) in `/content/seasons/01-soil/fragments.yaml` and a few long-form `.md` per the existing convention. - Implements `src/sim/memory/{selector,pool}.ts` (MEMR-06). - Implements `src/sim/garden/commands.ts` `harvest` + `compost` (GARD-03, GARD-04). - Implements `src/ui/journal/{Journal,FragmentRevealModal,journal-icon}.tsx` (MEMR-04, MEMR-05, D-23, D-24, D-25). - Implements GARD-04 compost tonal beat (Ink-authored line per D-07 model — see Open Question 2). - Implements PIPE-02 lazy-loading wiring in `src/content/loader.ts` and `scripts/check-bundle-split.mjs`. - Tests: vitest unit (selector deterministic / gated / no-dup) + integration (Journal renders fragments; selectable text; reveal modal). - E2E manual smoke: launch → plant → fast-forward → harvest → reveal modal → close → journal shows it. - Satisfies: GARD-03, GARD-04, MEMR-01, MEMR-02, MEMR-04, MEMR-05, MEMR-06, PIPE-02, UX-01 (Journal-icon-on-first-harvest reveal), UX-11 (number formatting in journal counts). **Wave 1 plans run in parallel.** Plan 02-02 and Plan 02-03 share `src/sim/garden/types.ts` (small surface, lock in Wave 0) and Plan 02-03 depends on Plan 02-02 only for the harvest action wiring; both can be drafted simultaneously and merged with a small integration moment. ### Wave 2: Two parallel vertical slices + e2e (3 plans) **Plan 02-04 — Vertical slice: Lura's Three Beats** - Replaces `npm run compile:ink` no-op stub with `scripts/compile-ink.mjs` invoking the `inklecate` package. - Authors `/content/dialogue/season1/lura-{arrival,mid,farewell}.ink` (Ink files). - Implements `src/sim/narrative/{lura-gate,beat-queue}.ts` (D-13, D-14, STRY-10). - Implements `src/ui/dialogue/{LuraDialogue,ink-runtime,ink-renderer}.tsx` (D-15, D-16, STRY-01, STRY-06). - Implements `src/render/garden/gate-renderer.ts` (gate visual + indicator on pending beat). - Tests: vitest unit (gate fires at 1st/4th/8th harvest; doesn't fire on FakeClock advance alone — STRY-10) + integration (Ink runtime drives drip cadence). - E2E manual smoke: harvest 1st fragment → gate glows → click gate → Lura arrival dialogue → continue. - Satisfies: STRY-01, STRY-06, STRY-07 (vacuous), STRY-10. **Plan 02-05 — Vertical slice: Letter from the Garden + Settings + Save Lifecycle Hooks + Playwright E2E (PIPE-07)** - Authors `/content/dialogue/season1/letter-from-the-garden.ink` (D-17, D-18) with the slot vocabulary from D-19. - Implements `src/sim/offline/events.ts` (`OfflineEventBlock` zod schema + aggregator from sim's silent-mode events). - Implements `src/ui/letter/{Letter,letter-renderer}.tsx` (D-20). - Implements `src/ui/settings/{Settings,persistence-toast}.tsx` (D-28, D-29, D-30). - Wires save-lifecycle hooks (visibilitychange, beforeunload, Season transition stub) per UX-10. - Implements `src/sim/garden/auto-harvest.ts` (D-10). - Implements URL-flag `?devtime=fake` FakeClock injection at boot for Playwright. - Authors `tests/e2e/season1-loop.spec.ts` (PIPE-07 full smoke). - Tests: vitest unit + integration (letter renders Ink slots; settings export round-trips; save fires on visibility change; URL-flag injects FakeClock); Playwright e2e green. - Satisfies: UX-02, UX-10, PIPE-07 + finishes any GARD-02-style save-survival assertions left dangling. **Wave 2 plans 02-04 and 02-05 run in parallel.** Plan 02-05 depends on plan 02-04's `src/sim/narrative/` only for the `lura_was_here` slot; plan 02-04 doesn't depend on 02-05. Both depend on Wave 1 work being merged. Plan 02-05 owns Playwright PIPE-07. ### Why this shape - **Wave 0 lands the three foundations together** because they're tightly coupled (the scheduler updates the store; BigQty values flow through state; the firewall rule enforces the boundary). Splitting them risks circular blocking. - **Wave 1's two slices each touch sim + store + render + ui** — true vertical slices, not horizontal layers. Plan 02-02's smoke test (plant a seed, see it grow) is end-to-end. Plan 02-03's smoke test (harvest, see fragment in journal) is end-to-end. - **Wave 2 isolates the heaviest authored content (Lura's Ink + the letter) into the latest plans** so the writer's work integrates against working mechanics, not against speculative shapes. - **PIPE-07 lives in the last plan** because it asserts the full loop. Earlier-plan e2e tests would assert partial loops — fragile. - **Each plan ships `npm run ci` green standalone.** No plan leaves CI red mid-phase. ## Sources ### Primary (HIGH confidence) - `.planning/phases/02-season-1-vertical-slice-soil/02-CONTEXT.md` — User decisions D-01 through D-34. - `.planning/REQUIREMENTS.md` — 24 Phase 2 REQ-IDs verbatim. - `.planning/PROJECT.md` — story bible synthesis, hard thematic constraints. - `.planning/ROADMAP.md` — Phase 2 success criteria (5). - `.planning/STATE.md` — Phase 1 verification table (16/16 PASS). - `.planning/anti-fomo-doctrine.md` — 17 banned UX patterns + review checklist. - `.planning/season-7-end-state.md` — principle-level rest-state contract. - `.planning/phases/01-foundations-and-doctrine/01-CONTEXT.md` — Phase 1 D-01..D-12 (save format, content pipeline, firewall locks). - `.planning/research/STACK.md` — locked stack rationale, version compatibility, content pipeline shape. - `.planning/research/ARCHITECTURE.md` — three-layer firewall, tick scheduler shape, six architectural patterns. - `.planning/research/PITFALLS.md` — 14 critical pitfalls (esp. #1, #4, #6, #7, #11, #12). - `CLAUDE.md` — stack lock, architectural firewall, banner concerns 1–10, code style. - `src/save/index.ts`, `src/save/migrations.ts`, `src/save/envelope.ts`, `src/save/codec.ts`, `src/save/db.ts`, `src/save/db-localstorage-adapter.ts`, `src/save/persist.ts`, `src/save/snapshots.ts` — Phase 1 save layer (frozen). - `src/content/index.ts`, `src/content/loader.ts`, `src/content/schemas/{fragment,season,index}.ts` — Phase 1 content pipeline. - `src/game/main.ts`, `src/game/scenes/Boot.ts` — Phaser entry. - `src/App.tsx`, `src/PhaserGame.tsx` — React shell + Phaser bridge. - `eslint.config.js` — flat config + `eslint-plugin-boundaries` element types. - `package.json` — exact dependencies + scripts (verified 2026-05-09). - `vitest.config.ts`, `playwright.config.ts`, `vite.config.ts` — test + build infrastructure. - `content/README.md` — content authoring conventions. - `content/seasons/00-demo/fragments.yaml` — demo to be removed in Phase 2. - `node_modules/inkjs/ink.d.mts` + `node_modules/inkjs/README.md` — inkjs API verification. - `node_modules/inklecate/README.md` + `node_modules/inklecate/package.json` — inklecate npm wrapper API. ### Secondary (MEDIUM confidence — verified via WebSearch + cross-referenced with Primary) - [Phaser 4 React template EventBus pattern (May 2025 announcement, prepared for Phaser 4)](https://phaser.io/news/2025/05/bringing-our-phaserjs-templates-into-the-future) — cross-referenced with `src/PhaserGame.tsx`'s template structure. - [Phaser input documentation — pointerdown on interactive game objects](https://docs.phaser.io/phaser/concepts/input) - [Phaser EventEmitter](https://docs.phaser.io/api-documentation/class/events-eventemitter) - [Zustand 5 createStore (vanilla)](https://zustand.docs.pmnd.rs/reference/apis/create-store) - [Zustand vanilla store usage outside React](https://github.com/pmndrs/zustand/discussions/1866) - [Vite import.meta.glob lazy code-splitting](https://vite.dev/guide/features) — confirmed default-lazy when `eager` is omitted/false. - [Web Audio Autoplay Policy + AudioContext.resume()](https://developer.chrome.com/blog/web-audio-autoplay) - [MDN Autoplay guide](https://developer.mozilla.org/en-US/docs/Web/Media/Guides/Autoplay) - [Fix Your Timestep — Gaffer On Games](https://gafferongames.com/post/fix_your_timestep/) - [break_eternity.js (Patashu)](https://github.com/Patashu/break_eternity.js) — TypeScript-native, ships `index.d.ts`. - [inkjs README — getting/setting variables](https://github.com/y-lohse/inkjs) - npm registry verification (`npm view version`) for phaser, zustand, break_eternity.js, inkjs, howler, inklecate (2026-05-09). ### Tertiary (LOW confidence — flagged for verification during planning) - The exact tile→DOM coordinate mapping under `Phaser.Scale.FIT` letterboxing (Assumption A5) — verify in Plan 02-02. - The `inklecate` npm wrapper's behavior on Windows with the `inputFilepath`/`outputFilepath` argument shape — verify with one real compile in Plan 02-04 (Assumption A6). ## Metadata **Confidence breakdown:** - Standard stack: HIGH — entire stack already locked + installed + verified. - Architecture: HIGH — patterns are documented in `.planning/research/ARCHITECTURE.md` and align with the existing scaffolded structure in `eslint.config.js`. - Tick scheduler: HIGH — canonical pattern from Gaffer On Games + research doc; one degree of design freedom (tick rate) is justified at 5Hz. - BigQty: HIGH — `break_eternity.js` is TypeScript-native + drop-in; the wrapper surface is small and obvious. - Zustand-Phaser bridge: HIGH — exact pattern is the official template's; `src/PhaserGame.tsx` is already shaped for it. - Ink integration: MEDIUM — `inkjs` and `inklecate` are installed and documented; the variable-substitution pattern is verified, but the inklecate Windows-binary invocation needs one real run in Plan 02-04. - Letter slot vocabulary: MEDIUM — three slots are ample for D-19's contract, but playtest may want more (acknowledged in Open Question 5 and Claude's Discretion). - Pitfalls: HIGH — derived from `.planning/research/PITFALLS.md` (14 documented + 2 phase-specific surfacings) plus implementation specifics. - Validation Architecture: HIGH — every requirement maps to a concrete test file; Wave-0 gaps explicit. **Research date:** 2026-05-09 **Valid until:** 2026-06-09 (30 days; stack is stable, no rapidly-evolving deps in Phase 2 surface) ## REQ-ID Coverage Map | REQ-ID | Requirement | Sections in this RESEARCH.md addressing it | |--------|-------------|-------------------------------------------| | CORE-02 | Deterministic, fixed-timestep sim advancing by elapsed real time | Pattern 1 (Tick Scheduler / Monotonic Clock); Architectural Responsibility Map row "Tick scheduler"; Validation Architecture row CORE-02 | | CORE-03 | Closed/returned game progresses by elapsed time, capped at 24h | Pattern 1; Boot path subsection; Validation Architecture row CORE-03 | | CORE-11 | Sim refuses negative deltas + caps offline at 24h | Pattern 1 (`drainTicks` rejects `accumulatorMs < 0`); Pitfall 1 + Common Pitfall 1; Security Domain row "system-clock manipulation"; Validation Architecture row CORE-11 | | GARD-01 | Plant a seed into unoccupied tile | Pattern 4 (Inline Seed Picker); Architectural Responsibility Map; MVP Slice Plan 02-02; Validation Architecture row GARD-01 | | GARD-02 | Visible growth state, advances over time, persists across save | Pattern 1 (sim ticks growth); Pattern 7 (V1Payload extension stores plant state); Architectural Responsibility Map; MVP Slice Plan 02-02 | | GARD-03 | Harvest mature plant → exactly one fragment; tile empties | MVP Slice Plan 02-03; Validation Architecture row GARD-03 | | GARD-04 | Compost immature plant + tonal beat | MVP Slice Plan 02-03; Open Question 2; Validation Architecture row GARD-04 | | MEMR-01 | Each harvest yields exactly one fragment from gated authored pool | Pattern (Fragment Selector — sim/memory module); MVP Slice Plan 02-03 | | MEMR-02 | Fragments authored in `/content/`, compiled per-Season at build | Already shipped Phase 1; Phase 2 drops Season-1 files (Pattern 8 lazy split); MVP Slice Plan 02-03 | | MEMR-03 | Stable string IDs (regex enforced) | Already enforced by `FragmentSchema`; Common Pitfall — numeric IDs; MVP Slice Plan 02-03 | | MEMR-04 | Memory Journal lists fragments by Season | Architectural Responsibility Map row "Memory Journal"; MVP Slice Plan 02-03 | | MEMR-05 | Selectable + copy-pasteable fragment text (DOM, not canvas) | Architectural Responsibility Map; Anti-Patterns ("Rendering Memory Journal text inside Phaser"); MVP Slice Plan 02-03 | | MEMR-06 | Deterministic selector with gating + no-dup until exhaustion | Pattern (Fragment Selector); Common Pitfall 8 (exhaustion); MVP Slice Plan 02-03 | | STRY-01 | Lura at gate during S1 with text-message-cadence Ink dialogue | Pattern 5 (Ink Compilation + Runtime); Architectural Responsibility Map; MVP Slice Plan 02-04 | | STRY-06 | All dialogue is `.ink` compiled to JSON | Pattern 5; MVP Slice Plan 02-04 (compile-ink.mjs) | | STRY-07 | Keeper has no dialogue beyond final binary choice (Phase 7) | Vacuously satisfied; documented in Phase Requirements table + Validation Architecture | | STRY-10 | Story progression gates on tick count, not wall time | Pattern 1 + Lura beat gating in `src/sim/narrative/`; Common Pitfall (tick-count gating); MVP Slice Plan 02-04 | | AEST-07 | First screen "Begin" gesture gate, calls `AudioContext.resume()` | Pattern 9 (AudioContext.resume() Bootstrap); Common Pitfall 5 (iOS Safari); Common Pitfall 9 (letter dismiss); MVP Slice Plan 02-02 | | UX-01 | Single Begin screen, no clutter; UI grows progressively | D-21, D-22, D-23 in User Constraints; A Dark Room rule applied across slices; MVP Slice Plan 02-02 (Begin) + Plan 02-03 (Journal reveals) | | UX-02 | Returning-player letter from the garden, in voice | Pattern 6 (Letter as Ink Template); MVP Slice Plan 02-05 | | UX-10 | Save on visibilitychange/beforeunload/Season transition | Pattern 7 (V1Payload extension); Common Pitfall 7 (beforeunload synchronous path); Open Question 3 (Season-transition hook is dormant Phase 2); MVP Slice Plan 02-05 | | UX-11 | Numbers as 1.2K / 4.5M / scientific past threshold | Pattern 2 (BigQty `format()` + `formatHumanReadable`); MVP Slice Plan 02-01 | | PIPE-02 | Player loads only current Season; future Seasons not in initial bundle | Pattern 8 (Per-Season Lazy Loading); MVP Slice Plan 02-03 | | PIPE-07 | Playwright e2e smoke covering full loop | Validation Architecture (Sim-Clock Injection); MVP Slice Plan 02-05 | **All 24 REQ-IDs are addressed.** Each maps to at least one architectural pattern, one or more files in the proposed structure, and at least one test in the Validation Architecture. --- *Research date: 2026-05-09* *Phase: 2 — Season 1 Vertical Slice (Soil)*