Files
TheLastGarden/.planning/phases/02-season-1-vertical-slice-soil/02-RESEARCH.md
T
josh d065922cad revise(02): mark Open Questions RESOLVED + tidy GrowthStage import order
- W1: 02-RESEARCH.md Open Questions section now flagged (RESOLVED) and each
  Recommendation prefixed with RESOLVED + a pointer to the artifact that
  codified the resolution.
- W8: 02-02 Plan example moves `import type { GrowthStage }` to the top of
  commands.ts (alongside the other type-only imports) and drops the trailing
  parenthetical apology — the executor doesn't need to fix anything.
2026-05-09 03:05:51 -04:00

125 KiB
Raw Blame History

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, 23 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: 23 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 12 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 = ~25 minutes (sprout → ready).
  • D-09: Per-plant durations vary (short / medium / longer) within the ~25min 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 410Hz). 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 25min band (D-08 / D-09).
  • Exact fragment-count threshold values for plant-type unlocks (D-05) and Lura beats (1st/4th/8th may shift ±12 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 410Hz).
  • 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 47.
  • 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<AudioContext | null>. 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)

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 version5.0.13 [VERIFIED 2026-05-09]
  • npm view break_eternity.js version2.1.3 [VERIFIED 2026-05-09]
  • npm view phaser version4.1.0 (already installed) [VERIFIED 2026-05-09]
  • npm view inkjs version2.4.0 (already installed) [VERIFIED 2026-05-09]
  • npm view inklecate version1.8.1 (already installed) [VERIFIED 2026-05-09]
  • npm view howler version2.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 + <canvas ref> 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 410Hz sim tick A 410Hz 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>; 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.
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               23 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 25min 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:

// 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,
  };
}
// 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):

// 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:

// 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:

// 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<AppStoreShape>()((set, get) => ({
  ...createGardenSlice(set, get),
  ...createMemorySlice(set, get),
  ...createNarrativeSlice(set, get),
  ...createSessionSlice(set, get),
}));

// React-side hook
export function useAppStore<T>(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:

// 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:

// 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 <SeedPicker> listens, mounts itself absolutely-positioned to those screen coords with a CSS arrow pointing at the tile.

// 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 <SeedPicker> 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:

"compile:ink": "node scripts/compile-ink.mjs"
// scripts/compile-ink.mjs (NEW Phase 2)
// Walks /content/dialogue/**/*.ink, calls inklecate per file,
// emits to src/content/compiled-ink/<season>/<name>.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:

// 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<Story> {
  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 <LuraDialogue> 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):

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 ~56 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:

// 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<number, Migration> = {
  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:

// 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<string, string>;

// 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<Fragment[]> {
  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).
// src/ui/begin/use-audio-bootstrap.ts
let _ctx: AudioContext | null = null;
let _resumed = false;

export async function bootstrapAudioContext(): Promise<AudioContext | null> {
  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:

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 110 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 12 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

// 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

// 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<AppStoreShape>()((set) => ({
  // … slice spreads
}));

// React side
export const useAppStore = <T>(selector: (s: AppStoreShape) => T): T =>
  useStore(appStore, selector);

Verified pattern: Tick scheduler accumulator (idle-game canonical)

// 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

// 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 25 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 + 12 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 23 plant types in Season 1 (per D-03 + D-09).

    • What we know: 23 plants, varying durations within 25min, 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, ~35 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.tsOfflineEventBlock 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 25 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 110 (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 (58 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 (23) 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 110, 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)

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)