- 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.
125 KiB
Phase 2: Season 1 Vertical Slice (Soil) - Research
Researched: 2026-05-09
Domain: Browser narrative idle game vertical slice (Phaser 4 canvas + React 19 DOM overlay + pure-sim core + Ink narrative + extended IndexedDB save)
Confidence: HIGH on stack/architecture (entire stack already locked + scaffolded in Phase 1, code-surface verified file-by-file). HIGH on tick scheduler / BigQty / Zustand-bridge patterns (Phaser 4's React EventBus pattern is the documented integration; idle-game tick-scheduler shape is canonical per .planning/research/ARCHITECTURE.md). MEDIUM on Ink↔Zustand bridge specifics (inkjs API verified in installed module; integration shape is ours to design). MEDIUM on Playwright fast-forward strategy (multiple viable mechanisms; the choice is partly ergonomic).
Summary
Phase 2 is a vertical slice on top of an already-laid foundation. Phase 1 shipped all the retrofit-hostile infrastructure: the save layer (IndexedDB + LocalStorage + CRC-32 + lz-string + Base64 + migration chain), the Vite-native content pipeline (Markdown+frontmatter / YAML, Zod-validated), the ESLint firewall (sim cannot import render or ui — eslint-plugin-boundaries flat config in eslint.config.js), the Phaser 4 + React 19 + Vite + TypeScript scaffold (src/game/main.ts, src/PhaserGame.tsx, src/App.tsx), the Vitest + Playwright test rig, the asset provenance gate, the doctrine docs, and npm run ci as the gate. Phase 2 lands the three deferred "day-one of feature code" foundations in a single phase (BigQty, the Zustand 5 store, the tick scheduler), then builds the vertical slice on top: 4×4 garden, 2–3 plant types, click+inline seed picker, sprout→mature→ready growth, harvest yields exactly one fragment, full-screen Memory Journal, three Ink-authored Lura gate visits gated on 1st/4th/8th harvest, authored Ink letter-from-the-garden, save-management Settings, Playwright e2e proving the full loop end-to-end.
The phase's load-bearing technical decisions are: (1) the tick scheduler is the only owner of wall-clock access — sim modules stay pure, time is injected, negative deltas refused, single offline catch-up clamped to 24h, with the Phaser update loop driving the scheduler from src/game/scenes/; (2) the BigQty wrapper around break_eternity.js lands in src/sim/numbers/ and is the type all economic values flow through, even though Season 1's actual numbers never need it (per CLAUDE.md "from day one of feature code" + Pitfall 7); (3) the Phaser↔React bridge uses Phaser 4's official template EventBus pattern (a Phaser.Events.EventEmitter instance accessible to both worlds) plus a Zustand 5 vanilla createStore — the sim writes to the store via a slim adapter (sim cannot import src/store/ directly, since the store imports from src/ui/ selectors), and React reads from it via hooks; (4) the save-schema extension updates V1Payload in-place per CONTEXT D-34 — Phase 1's v1 has shipped no production saves, so adding fields is type-extension, not a migrate_v1_to_v2, which keeps migrations[1]'s synthetic v0→v1 demo green; (5) the fragment selector is a pure function in src/sim/memory/ taking (state, currentSeason, gardenContext, fragmentPool, seed) → fragmentId, gating on Season + plant type + harvest count and tracking exhaustion via the existing harvestedFragmentIds array; (6) Ink integration uses inklecate (installed) compiled at build via an npm script that emits *.ink.json into src/content/compiled-ink/, with inkjs (installed, v2.4.0) loading the JSON at runtime; Lura's branches read game state by setting Ink variables from Zustand snapshots before Continue() is called.
Primary recommendation: Plan Phase 2 as 5 vertical-slice plans delivered in 3 waves: Wave 0 lands the three foundations (BigQty + Zustand store + tick scheduler) as a single non-feature plan that unblocks everything; Wave 1 lands two parallel vertical slices (the planting-and-growth slice + the journal-and-fragments slice); Wave 2 lands two more parallel slices (Lura gate visits + the offline-letter loop) and the Playwright e2e smoke. Each Wave-1+ plan must pass npm run ci standalone.
Architectural Responsibility Map
Each capability of the Phase 2 vertical slice is owned by a primary architectural tier, with the firewall enforced by eslint.config.js. Cross-tier communication goes through the Zustand store and the Phaser EventBus.
| Capability | Primary Tier | Secondary Tier | Rationale |
|---|---|---|---|
| Garden tile state (16 tiles, plant data, plantedAtTick) | src/sim/garden/ |
— | Pure data; sim mutates; rendering reads via store. |
| Plant growth state machine (sprout → mature → ready) | src/sim/garden/ |
— | Deterministic from (plantedAtTick, currentTick, plantType.duration). No DOM. |
| Tick scheduler / monotonic clock | src/sim/scheduler/ |
src/game/scenes/ (drives via Phaser update) |
Only owner of wall-clock. Sim's simulate(state, dtTicks) is pure; scheduler injects time. |
| Offline catch-up (24h cap, refuse negative dt) | src/sim/scheduler/ |
— | Pure function over saved state + delta. Fires silent: true to suppress UI side effects. |
| BigQty wrapper around break_eternity.js | src/sim/numbers/ |
— | Pure math; serialization helpers; no DOM. |
| Fragment selector (deterministic, gating, no-dup) | src/sim/memory/ |
— | Pure function over (state, fragments, seed). |
| Lura beat gating (1st/4th/8th harvest count) | src/sim/narrative/ |
— | Reads sim state's harvest count; enqueues beatId events. Does NOT load Ink content. |
| Auto-harvest during offline (D-10) | src/sim/garden/ |
— | Same simulate loop, with auto-harvest branch when player is absent (state flag). |
| Garden tile rendering (Phaser primitives) | src/render/garden/ |
— | Empty-tile outline + plant stage shape + ready-pulse via Phaser primitives. No DOM. |
| Begin screen (gesture gate + AudioContext.resume) | src/ui/begin/ |
— | Full-screen React DOM modal; first-run only per D-22; tap calls audioContext.resume(). |
| Memory Journal (full-screen modal) | src/ui/journal/ |
— | DOM per MEMR-05 (selectable / copy-pasteable); reveals after 1st harvest per D-23. |
| Lura dialogue overlay | src/ui/dialogue/ |
src/sim/narrative/ (gates), Ink runtime (renders) |
DOM-rendered per D-15 (selectable text from day one). |
| Letter-from-the-garden (full-screen modal) | src/ui/letter/ |
Ink runtime (templates) | DOM full-screen per D-20 (≥5min absence threshold). |
| Settings (Export/Import/Restore) | src/ui/settings/ |
src/save/ (already shipped) |
DOM modal; corner icon + hotkey per D-29. |
| Inline seed picker (popover over clicked tile) | src/ui/garden/ |
src/render/garden/ (provides tile screen position) |
DOM popover positioned over Phaser canvas via tile→screen coord conversion. |
| Persistence-result toast (D-30) | src/ui/toast/ |
src/save/persist.ts (data) |
One-time soft toast in voice on first save if denied. |
| Save persistence (already shipped) | src/save/ |
— | Phase 1's complete layer. Phase 2 imports only via src/save/index.ts. |
| Save lifecycle hooks (visibilitychange, beforeunload, Season transition) | src/save/ (existing) + src/PhaserGame.tsx (subscribes) |
— | Browser event listeners live with the React shell; serialize calls go through the save barrel. |
| Content loader (already shipped) | src/content/ |
— | Phase 2 drops /content/seasons/01-soil/*.{md,yaml} and /content/dialogue/season1/*.ink. |
| Ink runtime bridge | src/sim/narrative/ (gates only) + src/ui/dialogue/ (rendering) |
— | inkjs Story instances live in UI tier; sim never imports inkjs. |
| Phaser ↔ React EventBus | src/PhaserGame.tsx (mounts) + scenes + UI |
Zustand store (alternate channel) | Per Phaser 4 official template. Used for one-shot scene-ready / scene-event signals; persistent state goes through Zustand. |
Architectural firewall reminder (CORE-10, ESLint-enforced): src/sim/ cannot import from src/render/ or src/ui/. The store sits in src/store/ and is allowed both directions (sim writes; UI reads). The Phaser scene (src/game/) reads from the store and writes commands; it does NOT import sim modules directly — it dispatches commands via the store, and the scheduler picks them up.
User Constraints (from CONTEXT.md)
Locked Decisions
(Copied verbatim from 02-CONTEXT.md. Numbering preserved.)
Garden Geometry & Input
- D-01: Garden is a 4×4 fixed grid (16 tiles).
- D-02: Seed placement is click-empty-tile → inline seed picker (small popover anchored to the clicked tile, listing currently-unlocked plant types; single tap commits). No persistent seed sidebar.
- D-03: 2–3 plant types ship in Season 1. Each plant has its own growth duration and fragment pool.
- D-04: Infinite seed supply from start — anti-FOMO. Meaningful constraint is time, not seed inventory.
- D-05: First plant available from start; remaining 1–2 unlock by fragment-count thresholds harvested.
- D-06: Empty tile look = faint outlined tile + subtle hover state (Phaser primitive draw).
- D-07: Post-harvest tile returns immediately to empty with a brief acknowledgement beat.
Time Density (Growth + Offline)
- D-08: First-plant active-play growth = ~2–5 minutes (sprout → ready).
- D-09: Per-plant durations vary (short / medium / longer) within the ~2–5min band.
- D-10: Auto-harvest during offline; manual in active play. Auto-harvested plants queue into the offline event log; the letter narrates them.
- D-11: 24h offline cap is surfaced silently in the letter's voice — no numeric "you were gone for 28 hours" copy.
Lura's Season 1 Arc
- D-12: Lura is present as discrete visits at the gate — not a persistent chat thread.
- D-13: 3 beats in Season 1: arrival · mid · farewell.
- D-14: Beats gated by fragment-count thresholds — beat 1 after 1st harvest; mid after 4th; farewell after 8th. Counts come from sim ticks, not wall time (STRY-10).
- D-15: Beat-fire UX = subtle gate indicator + player-initiated visit. Conversation opens as a React DOM dialogue overlay.
- D-16: All Lura dialogue is authored in Ink (
.ink) under/content/dialogue/.
Letter-from-the-Garden (UX-02)
- D-17: Letter composed from authored skeleton + templated insertions. Hand-authored Ink passages with named variable slots; specifics flow in from the offline event log.
- D-18: Letter authoring lives in Ink (
.inkfiles in/content/dialogue/). - D-19: Save schema gains a small
offlineEventsblock: 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:
BigQtywrapper aroundbreak_eternity.jsis Phase 2's first task. Lands insrc/sim/numbers/. - D-32: Zustand 5 store is the bridge between Phaser scene and React UI. Sim writes to the store; React reads. Sim never imports the store directly.
- D-33: Tick scheduler / monotonic clock is the only owner of wall-clock access. Tick rate is Claude's discretion (likely 4–10Hz). Negative deltas refused; offline catch-up clamps to 24h.
- D-34: Save extension for Phase 2 updates
V1Payload(insrc/save/migrations.ts) — Phase-2-scope schema extension (not a v1→v2 migration).
Claude's Discretion
- Specific growth-duration values per plant type within the 2–5min band (D-08 / D-09).
- Exact fragment-count threshold values for plant-type unlocks (D-05) and Lura beats (1st/4th/8th may shift ±1–2 during playtest).
- Form of the post-harvest acknowledgement beat (D-07).
- Form of the gate indicator when Lura's beat unlocks (D-15).
- Tick rate / sim cadence (likely 4–10Hz).
- Internal shape of the Zustand store slices.
- Internal shape of the scene/state machine inside Phaser (Boot → Preloader → Garden, or simpler).
- Specific copy of the Begin screen, the persistence-denied toast, and the post-harvest acknowledgement (all reviewed by user).
- Choice of e2e test fast-forward mechanism (hidden dev hotkey vs URL flag vs sim-clock injection).
- Specific copy of the Memory Journal "no fragments yet" empty state.
- Whether the offline letter's slot vocabulary is finalized in this phase or expanded incrementally.
Deferred Ideas (OUT OF SCOPE)
- Hybrid Lura presence (gate visits + ambient text-message drip) — rejected for Phase 2 in favor of pure discrete gate visits (D-12). May be reconsidered Phase 4+.
- Plant-type unlocks tied to specific authored fragments — rejected for Phase 2 in favor of fragment-count thresholds (D-05). Phase 4+ may explore narrative-keyed unlocks.
- Fully procedural letter from event-log templates — rejected (D-17). Phase 2 commits to authored skeleton + slots.
- Audio sliders (UX-04), keyboard nav (UX-06), browser-zoom guarantees (UX-07), color-redundant icons (UX-08), tab-title bloom (UX-09), Lura-not-numbers UX (UX-12) — all confirmed for Phase 8.
- Visual regression for asset library (PIPE-04) — Phase 8.
- Roothold prestige currency, Season transitions, die-off, finite-ceiling enforcement — Phase 4. Phase 2 plants nothing in the save schema for Roothold.
- Cross-pollination, Memory Storms, place-memory vignettes, ecosystem planting, the Below, the Loom, the Archivist, Lura's full multi-Season arc, the Nameless Man — Phase 4–7.
- Watercolor post-process, painted plants, painted Begin screen, solo cello + ambient buses + crossfade, reduced-motion toggle (UX-05) — Phase 3.
- Real production-volume AI assets + locked north-star reference set — Phase 5 follow-up. Phase 2 ships zero AI-generated assets (D-26 = primitive shapes).
- Real
migrate_v1_to_v2— Phase 4. Phase 2 only extendsV1Payloadshape (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:
- Lazily creates
new AudioContext()(withwebkitAudioContextfallback for Safari). - Calls
audioContext.resume()and awaits it. - Returns the context (Phase 3 will retrieve it via the Zustand store and feed Howler).
- On any error or unsupported browser, returns
nulland 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 version→5.0.13[VERIFIED 2026-05-09]npm view break_eternity.js version→2.1.3[VERIFIED 2026-05-09]npm view phaser version→4.1.0(already installed) [VERIFIED 2026-05-09]npm view inkjs version→2.4.0(already installed) [VERIFIED 2026-05-09]npm view inklecate version→1.8.1(already installed) [VERIFIED 2026-05-09]npm view howler version→2.2.4(NOT installed in Phase 2; deferred to Phase 3) [VERIFIED 2026-05-09]
Alternatives Considered (and rejected for Phase 2)
| Instead of | Could Use | Tradeoff |
|---|---|---|
| Phaser 4 EventBus + Zustand | Mitt-only event bus | Mitt is a fine event emitter, but Phaser's official template already provides an EventBus (Phaser.Events.EventEmitter) — using it removes a dependency and matches the documented Phaser+React pattern. Persistent state still needs Zustand; events alone do not survive reloads. |
| Zustand 5 react binding | Vanilla createStore only |
Vanilla works fine for the sim side, but the React side needs the hook-style binding (useStore) for clean re-renders. Use Zustand 5's pattern: createStore from zustand/vanilla for the underlying store + useStore from zustand for React subscriptions. [CITED: zustand.docs.pmnd.rs/reference/apis/create-store] |
BigQty as direct re-export of Decimal |
Hand-roll wrapper class | The wrapper is non-negotiable per CLAUDE.md ("Never raw Decimal values in app code"). Re-export-only would let raw Decimals leak into call sites. |
| Inline R3F-style React-Phaser bridge | Phaser 4 official EventBus + <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 | 4–10Hz sim tick | A 4–10Hz sim tick is the canonical idle-game rate per .planning/research/ARCHITECTURE.md Pattern 2 (5Hz example) and gives 24h × 3600 × 5 = 432,000 ticks per offline cap, comfortably catchable in <100ms. 60Hz would need 5.18M ticks for the same offline window — pointless cost when growth resolution is in minutes. |
| Phaser scene tree Boot → Preloader → Garden | Single Garden scene | Phase 2 has near-zero asset loading (no PNG plants — D-26 says primitives only). A single Garden scene plus a tiny Boot is enough; Preloader becomes meaningful in Phase 3 when watercolor textures arrive. |
Architecture Patterns
System Architecture Diagram
BROWSER TAB
┌─────────────────────────────────────────────────────────────────────┐
│ React 19 DOM Overlay (z-index above canvas) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Begin │ │ Journal │ │ Letter │ │ Dialogue│ │ Settings │ │
│ │ Screen │ │ (modal) │ │ (modal) │ │ (overlay)│ │ (modal) │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │ │ │
│ │ └────┬───────┴────┬───────┴────┬───────┘ │
│ ┌────┴────────────────┴────┐ ┌─────┴────────────┴─────┐ │
│ │ Inline Seed Picker │ │ HUD chrome (corner │ │
│ │ (DOM popover positioned │ │ icons + journal │ │
│ │ over Phaser tile) │ │ icon, optional) │ │
│ └────┬─────────────────────┘ └────────────┬───────────┘ │
│ │ │ │
│ │ read state, dispatch commands │ │
│ ↓ ↓ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ Zustand 5 Store (createStore from zustand/vanilla,│ │
│ │ read in React via useStore) │ │
│ │ ┌─────────┐ ┌─────────┐ ┌──────────┐ ┌─────────┐ │ │
│ │ │ garden │ │ memory │ │ narrative│ │ session │ │ │
│ │ │ slice │ │ slice │ │ slice │ │ slice │ │ │
│ │ └─────────┘ └─────────┘ └──────────┘ └─────────┘ │ │
│ └─────────┬────────────────────────────────────┬─────┘ │
│ │ command queue (intent only) │ │
│ ↓ ↑ │
│ ┌─────────────────────────┐ ┌─────────────────────────┐ │
│ │ Phaser 4 Scene Tree │ │ Pure Sim (src/sim/) │ │
│ │ (src/game/, src/render/)│ read │ ┌──────────────────┐ │ │
│ │ ┌─────────────────┐ │ state │ │ scheduler │ │ │
│ │ │ Boot → Garden │←────┼─────────┼─│ (only owner of │ │ │
│ │ │ scene │ │ │ │ wall-clock) │ │ │
│ │ └─────────────────┘ │ │ └────────┬─────────┘ │ │
│ │ • tile rendering │ │ │ injects t │ │
│ │ • plant primitives │ write │ ↓ │ │
│ │ • ready-pulse tween │ state │ ┌────────────────┐ │ │
│ │ • pointerdown handler │←────────┼─│ simulate(s,dt) │ │ │
│ │ • Phaser EventBus │ events │ │ garden.tick │ │ │
│ │ (scene-ready, etc.) │ │ │ growth.advance│ │ │
│ └─────────────────────────┘ │ │ fragment.sel │ │ │
│ │ │ narrative.gate│ │ │
│ ┌──────────────────────────────────┐│ │ numbers/BigQty│ │ │
│ │ Browser event listeners ││ └────────────────┘ │ │
│ │ visibilitychange → save │└──────────┬──────────────┘ │
│ │ beforeunload → save │ │ │
│ │ click → AudioContext.resume() │ │ │
│ └──────────────────────────────────┘ │ │
│ │ │
│ ┌──────────────────────────────────────────────┴──────────────┐ │
│ │ src/save/ (Phase 1, frozen barrel) │ │
│ │ wrap/unwrap, snapshot, requestPersistence, │ │
│ │ exportToBase64/importFromBase64, openSaveDB, │ │
│ │ migrate, V1Payload (extended Phase 2) │ │
│ └────────────────────────────┬────────────────────────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────┐ │
│ │ IndexedDB (primary) + │ │
│ │ LocalStorage fallback │ │
│ │ Stores: saves, save_snapshots │ │
│ └──────────────────────────────────┘ │
│ │
│ Build time: /content/seasons/01-soil/*.{md,yaml} │
│ /content/dialogue/season1/*.ink │
│ ── Vite import.meta.glob (per-Season lazy from Phase 2) ── │
│ ── inklecate compile → src/content/compiled-ink/*.ink.json ── │
└─────────────────────────────────────────────────────────────────────┘
Data flow summary:
- Boot:
main.tsxmountsApp;Appmounts<PhaserGame>;PhaserGame.tsxcreates Phaser; Boot scene transitions to Garden scene; Garden scene wires the tick scheduler into itsupdate()callback. - Resume from save:
src/save/index.tsis 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 anofflineEventsaggregate that triggers the letter overlay if absence ≥ 5min. - First run: No save → Begin screen mounts; tap calls
AudioContext.resume()and dismisses the Begin screen; Garden scene activates. - Live tick: Garden scene's
update(time, delta)callsscheduler.tick(now). Scheduler accumulates wall-clockdelta, drains in fixed-sizeTICK_MSchunks, callssimulate(state, dt, commands), applies the result to the Zustand store. Render reads from the store. - 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. - Harvest: Player clicks ready tile →
harvestcommand queued → simulate runsharvestPlant→ fragment selector picks one fragment id →harvestedFragmentIdsarray gets a new entry → store updates → React renders fragment-reveal modal. - Lura beat:
narrative.gatechecksharvestedFragmentIds.lengthafter each harvest; on count ∈ {1, 4, 8} (config-tunable), enqueues apendingLuraBeat: 'arrival'|'mid'|'farewell'flag; the gate icon glows; player taps gate → React loads compiledseason1/lura-arrival.ink.jsoninto a fresh inkjsStory, sets variables from the store snapshot, drives the dialogue overlay. - Save on visibility hidden / beforeunload: Listener invokes the save layer's
wrap+ IndexedDB write path. Save is small (<10KB) so synchronous serialization completes well withinbeforeunload's tight window.
Recommended Project Structure (Phase 2 additions)
src/
├── sim/ (NEW Phase 2 — was empty firewall dir)
│ ├── numbers/
│ │ ├── big-qty.ts D-31; wrapper around break_eternity.js
│ │ ├── big-qty.test.ts
│ │ └── format.ts UX-11 human-readable display (1.2K, 4.5M, …)
│ ├── scheduler/
│ │ ├── clock.ts D-33; the only owner of Date.now()
│ │ ├── tick.ts fixed-timestep accumulator + simulate dispatcher
│ │ ├── catchup.ts CORE-03/CORE-11; refuse negative dt; clamp 24h
│ │ └── *.test.ts
│ ├── garden/
│ │ ├── types.ts Tile + PlantInstance + PlantType
│ │ ├── plants.ts 2–3 PlantType definitions (durations, fragmentPool)
│ │ ├── growth.ts sprout → mature → ready state machine
│ │ ├── commands.ts plantSeed / harvest / compost (pure)
│ │ ├── auto-harvest.ts D-10 offline auto-harvest branch
│ │ └── *.test.ts
│ ├── memory/
│ │ ├── selector.ts MEMR-06 deterministic, gating, no-dup
│ │ ├── selector.test.ts
│ │ └── pool.ts reads from src/content/, applies Season + plant gate
│ ├── narrative/
│ │ ├── lura-gate.ts D-14 1st/4th/8th harvest count → pending beat
│ │ ├── lura-gate.test.ts
│ │ └── beat-queue.ts store-shape contract for pending Lura beats
│ ├── offline/
│ │ ├── events.ts OfflineEvent schema (zod) + aggregator
│ │ └── events.test.ts
│ ├── state.ts SimState root shape + serialization (matches V1Payload)
│ └── index.ts barrel; the application layer imports from here
│
├── store/ (NEW Phase 2)
│ ├── garden-slice.ts 16 tiles + plant-type unlocks + commands
│ ├── memory-slice.ts harvested fragment ids + reveal modal state
│ ├── narrative-slice.ts Lura beat queue + dialogue overlay state
│ ├── session-slice.ts Begin gate state + persistence-result toast
│ ├── store.ts composes slices with createStore from zustand/vanilla
│ ├── selectors.ts React-friendly selectors for components
│ └── index.ts
│
├── save/ (FROZEN Phase 1 barrel; Phase 2 only edits migrations.ts)
│ └── migrations.ts extends V1Payload per D-34
│
├── content/ (FROZEN Phase 1 barrel; Phase 2 adds Season-1 lazy split)
│ ├── ink-loader.ts (NEW) runtime loader for compiled-ink JSON
│ ├── compiled-ink/ (GENERATED, gitignored) by `npm run compile:ink`
│ │ └── season1/
│ │ ├── lura-arrival.ink.json
│ │ ├── lura-mid.ink.json
│ │ ├── lura-farewell.ink.json
│ │ └── letter-from-the-garden.ink.json
│ └── (existing Phase 1 modules unchanged)
│
├── render/
│ └── garden/
│ ├── tile-renderer.ts D-06 outlined tile, hover state
│ ├── plant-renderer.ts D-26 primitive shapes per growth stage
│ ├── ready-pulse.ts D-27 alpha cycle / shader pulse
│ ├── gate-renderer.ts gate visual + indicator on pending Lura beat (D-15)
│ └── tile-coords.ts tile↔screen coord conversion (used by seed picker)
│
├── ui/
│ ├── begin/
│ │ ├── BeginScreen.tsx D-21 typographic placeholder
│ │ └── use-audio-bootstrap.ts AudioContext.resume() on tap
│ ├── journal/
│ │ ├── Journal.tsx D-24 full-screen modal
│ │ ├── FragmentRevealModal.tsx D-25 active-play harvest reveal
│ │ └── journal-icon.tsx D-23 reveals after 1st harvest
│ ├── letter/
│ │ ├── Letter.tsx D-20 full-screen, ≥5min absence
│ │ └── letter-renderer.ts drives inkjs Story + variable bindings
│ ├── dialogue/
│ │ ├── LuraDialogue.tsx D-15 React DOM overlay
│ │ ├── ink-renderer.tsx text-message-cadence drip
│ │ └── ink-runtime.ts inkjs Story instantiation + variable wiring
│ ├── settings/
│ │ ├── Settings.tsx D-28 Export/Import/Restore
│ │ └── persistence-toast.tsx D-30 in-voice soft toast
│ └── garden/
│ └── SeedPicker.tsx D-02 inline popover positioned over canvas
│
├── game/
│ ├── main.ts EXPAND scene list to include Garden
│ ├── event-bus.ts (NEW) Phaser.Events.EventEmitter singleton (per Phaser 4 template)
│ └── scenes/
│ ├── Boot.ts EXPAND: transition to Garden after `MainScene.create`
│ └── Garden.ts (NEW) the canvas scene; wires tick scheduler + tile input
│
└── PhaserGame.tsx EXPAND: subscribe to event-bus, expose audio context, register lifecycle save hooks
content/
├── seasons/
│ ├── 00-demo/ REMOVED Phase 2 (per CONTEXT canonical_refs)
│ └── 01-soil/ NEW
│ ├── fragments.yaml bulk Season-1 fragments (≥8 to satisfy 8th-harvest threshold)
│ └── fragments/
│ └── *.md long-form fragments (one-per-file, frontmatter)
└── dialogue/ NEW (was empty)
└── season1/
├── lura-arrival.ink
├── lura-mid.ink
├── lura-farewell.ink
└── letter-from-the-garden.ink
tests/
└── e2e/ NEW Phase 2
└── season1-loop.spec.ts PIPE-07 smoke
Pattern 1: Tick Scheduler / Monotonic Clock
What: A scheduler module in src/sim/scheduler/ that owns all wall-clock access (Date.now() / performance.now()), accumulates real-time deltas, drains them in fixed-size TICK_MS chunks, and calls simulate(state, dtTicks, commands) → state'. It is the single boundary point between wall time and sim time.
When to use: Always for the live tick. Same module also handles offline catch-up.
Recommended tick rate: 5 Hz (TICK_MS = 200). Rationale:
- A 24h offline catch-up =
24 × 3600 × 5 = 432,000ticks. Even at 1µs per tick, that catches up in ~0.4s — well within "boot to playable". - Plant growth in the 2–5min band has natural granularity (a 5-min plant ticks 1500 times sprout→ready; plenty of resolution for sprout/mature/ready transitions).
- Render runs at
requestAnimationFrame(60Hz on most displays); sim runs at 5Hz independently. They're decoupled by design. - 5Hz matches the example in
.planning/research/ARCHITECTURE.mdPattern 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 insrc/sim/garden/,src/sim/memory/,src/sim/narrative/— would silently bypass the FakeClock. The ESLintno-restricted-syntaxrule should be added forCallExpression[callee.object.name='Date'][callee.property.name='now']insidesrc/sim/**exceptsrc/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 plainnumber; 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:
- Persistent state lives in a Zustand 5 store created via
createStorefromzustand/vanilla(works without React). The sim writes to it via a slim adapter; React components read from it via theuseStorehook. - Transient events ("scene-ready", "tile-clicked-with-coords") flow through a
Phaser.Events.EventEmittersingleton — the official Phaser 4 React-templateEventBuspattern. [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 absencefragment_titles: string— comma-joined human-readable list of just-collected fragments (or""if none)lura_was_here: boolean— whether a beat ticked while the player was away (the gate indicator was queued)
The writer expands the slot list as new Ink expressions need state; Phase 2 may grow from these three to ~5–6 by end of phase. CLAUDE.md "voice anchor" rule means the Ink reads as authored fiction; the slots are filled at runtime.
Pattern 7: Save-Schema Extension (NOT a migration)
What: Per CONTEXT D-34 and .planning/phases/01-foundations-and-doctrine/01-CONTEXT.md D-04, Phase 1's v1 envelope has shipped no production saves. Phase 2 extends V1Payload in place — adds new fields with sensible defaults — and the existing migrations[1] synthetic v0→v1 demo continues to work because v0 → v1 already sets up the new defaults via the same factory function.
Concrete edit to src/save/migrations.ts:
// 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 ownsmigrations[2]per Phase 1's locked decision. - Reading
lastTickAtdirectly from save without going through the scheduler — this would letDate.now()leak into sim modules. - Storing the offline letter's body text in the save. Only the structured
offlineEventsblock 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
suspendedstate 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 callsscheduler.tick(state), gets backnext state, callssimAdapter.applySimResult(next, events)which lives insrc/store/. - Naive offline catch-up (
fragments += rate * elapsedSeconds) — misses non-linear interactions and Lura beat unlocks. Run the actualsimulate()loop withsilent: true(.planning/research/PITFALLS.mdAnti-Pattern 7). if (!state.luraBeatProgress) state.luraBeatProgress = {...}at read sites — that's a hidden migration. Instead, fix the migration once inmigrations[1](Phase 2) and requireV1Payloadto 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()inuseEffectwithout 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
setTimeouttriggers — 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 tosrc/sim/**excludingsrc/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 1–10 seconds of progress.
Pitfall 8: Fragment selector returns duplicates after exhaustion
What goes wrong: MEMR-06 says "no duplicates within a single playthrough until the pool is exhausted." Once exhausted, the spec is silent. If the selector throws, harvests after exhaustion break.
How to avoid: When all fragments in the gated pool have been harvested, the selector falls back to a "memory-of-a-memory" pool (a single fragment authored as the exhaustion marker, e.g., season1.soil.gardener-knows-this-one-already) OR repeats the most recently harvested one (least bad option for UX). Phase 2 should ship enough Season-1 fragments that exhaustion is unlikely (target ≥10 to comfortably exceed the 8th-harvest Lura threshold + plant-type unlocks). Document the chosen behavior in PLAN.md.
Warning signs: Player reaches a state where harvest does nothing; or duplicate fragments appear unexpectedly.
Pitfall 9: Letter overlay swallows tap-to-resume gesture
What goes wrong: Player returns after >5min. Letter overlay mounts. Player taps to dismiss → letter dismisses but AudioContext.resume() is not called because no bootstrapAudioContext() handler is registered on the letter's dismiss button.
How to avoid: The letter's dismiss button calls bootstrapAudioContext() exactly like the Begin button. Same for any first-interaction surface a returning player might hit before the live garden.
Warning signs: Audio fails for returning players who land directly in the letter.
Pitfall 10: Plant-type unlock thresholds vs fragment-count off-by-one
What goes wrong: D-05 says "remaining 1–2 unlock by fragment-count thresholds." Player has 5 harvests. Threshold for plant 2 is 5. Off-by-one bug: unlock fires before the 5th harvest is committed to the store.
How to avoid: Always check thresholds after the harvest commit, in the same simulate-step's post-step hook. Test the boundary (4 harvests = locked, 5 harvests = unlocked) explicitly.
Warning signs: Race condition between unlock and reveal modal.
Code Examples
Verified pattern: Phaser scene → React EventBus signaling scene-ready
// 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_ENVin browser code — Vite usesimport.meta.env(already used by Phase 1).phaser/types/Phaserdeep imports — Phaser 4 exports types from the package root.
Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|---|---|---|
| A1 | The Phaser 4 EventBus pattern (Phaser.Events.EventEmitter singleton) is the documented Phaser-React integration in Phaser 4's official template, just as it was in Phaser 3's template. |
Pattern 3 + System Architecture Diagram | LOW — verified via [phaser.io/news/2025/05/bringing-our-phaserjs-templates-into-the-future]; the template's EventBus pattern explicitly says the May 2025 update prepared the template for Phaser 4. If Phaser 4's release shifted this, Phase 2 falls back to mitt which is already research-confirmed in .planning/research/STACK.md. |
| A2 | A 5Hz sim tick rate (TICK_MS=200) is appropriate for 2–5 minute plant growth. | Pattern 1 | LOW — derived from .planning/research/ARCHITECTURE.md Pattern 2's worked example. If playtest shows visible stepping in growth animation, Phase 2 can bump to 10Hz (TICK_MS=100) without changing any sim code (only the constant). |
| A3 | A 24h offline catch-up at 5Hz (=432K ticks) catches up in well under 1s on a modern device. | Pattern 1 | LOW — the per-tick work is small (state mutation + accumulator drain); but if the simulate function ends up doing string allocations per tick, this could degrade. Vitest benchmark in scheduler tests should assert ≤500ms for 432K ticks. |
| A4 | Ink variables (VAR plants_bloomed = 0) set via story.variablesState['plants_bloomed'] = N work for letter templating in inkjs 2.4.0. |
Pattern 6 | LOW — verified via [github.com/y-lohse/inkjs README "Differences with the C# API: Getting and setting ink variables"]; this is the documented mechanism. |
| A5 | Phaser pointer event coordinates (pointer.x, pointer.y) are in CSS-pixel space and can be used directly as DOM position: absolute coords for the seed picker without getBoundingClientRect adjustments. |
Pattern 4 | MEDIUM — depends on the canvas's CSS layout. Phase 1's scaffold uses Phaser.Scale.FIT, which adds letterboxing. The popover may need to getBoundingClientRect the #game-container and add its left/top offset. Plan 2 should test on a non-fullscreen window. |
| A6 | inklecate (the npm wrapper around the .NET binary) runs cleanly on Windows + macOS + Linux CI. |
Pattern 5 | MEDIUM — inklecate.exe ships in node_modules/inklecate/bin/; the wrapper picks the right binary per platform. Phase 2's compile-ink.mjs script must not assume a specific binary path; it should call the inklecate package's exported function. The package was installed in Phase 1 but never invoked beyond the no-op stub — Plan 2 should confirm with one real compile on Windows-the-dev-machine before authoring all Lura content. |
| A7 | Phase 2's letter rendering uses Ink variable substitution + conditional branches, not EXTERNAL functions. |
Pattern 6 | LOW — variable substitution is sufficient for D-19's slot vocabulary; flagged here so reviewers know we explicitly chose simpler over more powerful. |
| A8 | Authoring "≥10 Season-1 fragments" is sufficient to satisfy 1st/4th/8th Lura thresholds + 1–2 plant-type unlocks + buffer. | Pitfall 8 | LOW — covers the worst case (8 harvests + 1 plant-type unlock margin). User reviews specific count during planning. |
| A9 | The first-interaction gesture handler installed on returning-player loads (Pattern 9) is reliable across browsers (no edge case where audio fails silently). | Pattern 9 | LOW — the click+touchstart+keydown triple-listener is the documented pattern. iOS quirks were the historical bug; modern Safari respects resume() after any user gesture. Phase 8 e2e test on iOS Safari is the right place to verify. |
| A10 | Per-Season lazy loading via import.meta.glob({ eager: false }) produces real chunk splits for Phase 2 even with only one Season authored. |
Pattern 8 | LOW — Vite always splits each lazy import; the chunk happens to be a single file in Phase 2, but the wiring is correct for Phase 4+. Verify with a npm run build && ls dist/assets/ showing a season1-named chunk. |
If this table is empty: All claims in this research were verified or cited — no user confirmation needed.
This table has 10 entries; all are LOW risk except A5 and A6 (MEDIUM). Plan 2 should explicitly verify A5 (canvas-to-DOM coord mapping under FIT scale) and A6 (inklecate wrapper invocation) early.
Open Questions (RESOLVED)
All five open questions resolved during /gsd-discuss-phase 2 + planning. Resolutions are codified in CONTEXT.md decisions and the per-plan PLANs. Recommendations below are ratified, not pending.
-
Plant-type identity per the 2–3 plant types in Season 1 (per D-03 + D-09).
- What we know: 2–3 plants, varying durations within 2–5min, distinct fragment pools, distinct visual primitive (different tint per plant), tonal identity per plant. First plant from start; remaining unlock at fragment-count thresholds.
- What's unclear: the actual identities (e.g., "rosemary vs. yarrow vs. winter-rose"?). Bible says "real flora, slightly wrong" but Phase 2 doesn't need painted flora — names matter for fragment authoring.
- RESOLVED: Plan 2's content authoring step proposes 3 names tied to 3 fragment-pool tonal registers (warm / contemplative / heavy). Locked names: rosemary (warm), yarrow (contemplative), winter-rose (heavy). See Plan 02-02 PLANT_TYPES.
-
Compost UX shape (GARD-04 "tonal beat acknowledging the choice to let go").
- What we know: composting is a Phase 2 mechanic; tonal beat must "acknowledge the choice to let go". Bible voice is warm + specific.
- What's unclear: is the beat a small text snippet, a particle effect, a sound? D-07 leaves form to Claude's discretion for harvest; GARD-04 is the compost analog and isn't in CONTEXT.
- RESOLVED: a single Ink-authored line per compost (
content/dialogue/season1/compost-acknowledgements.ink, ~3–5 short lines, randomly selected). Authored in Plan 02-04. UI-wiring deferral acknowledged in 02-04 SUMMARY → 02-05 toast surface.
-
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 fromsrc/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.
-
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.
-
Should the offline letter ALSO surface the just-unlocked Lura beat indicator, or are they decoupled?
- What we know: D-19 says
offlineEventsincludes "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.
- What we know: D-19 says
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); manualnpx playwright testfor Wave 2C - Phase gate:
npm run cigreen ANDnpx playwright testgreen 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 invariantssrc/sim/scheduler/tick.test.ts— accumulator drain math + tick ratesrc/sim/scheduler/catchup.test.ts— 24h cap + negative-delta refusalsrc/sim/numbers/big-qty.test.ts— arithmetic + comparison + serialization round-tripsrc/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 + selectorssrc/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 machinesrc/sim/garden/auto-harvest.test.ts— D-10 offline branchsrc/ui/begin/BeginScreen.test.tsx— tap calls bootstrapAudioContextsrc/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 + exhaustionsrc/ui/journal/Journal.test.tsx— list + select + copysrc/ui/journal/FragmentRevealModal.test.tsx— D-25 reveal flowsrc/content/loader.test.tsextension — Season-1 fragments load via lazy pathscripts/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 semanticssrc/ui/dialogue/LuraDialogue.test.tsx— Ink runtime integration + drip cadencesrc/ui/dialogue/ink-runtime.test.ts— variable wiring
Wave 2B (offline letter) gaps:
src/sim/offline/events.test.ts—OfflineEventBlockschema + aggregatorsrc/ui/letter/Letter.test.tsx— Ink template render + dismisssrc/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 insrc/sim/scheduler/clock.ts+ boot wiring
No framework install needed — Vitest, Playwright, fake-indexeddb, happy-dom are all already on disk from Phase 1.
Sim-Clock Injection for Playwright Fast-Forward
The Phase 2 e2e (PIPE-07) needs to fast-forward growth without waiting 2–5 minutes per plant. Three viable mechanisms:
| Mechanism | How | Pros | Cons |
|---|---|---|---|
URL flag (?devtime=fake) + FakeClock injection ⭐ |
Boot reads URLSearchParams; if devtime=fake, the scheduler binds to a FakeClock instance exposed on window.__tlgFakeClock. Playwright calls await page.evaluate(() => window.__tlgFakeClock.advance(5 * 60 * 1000)) to fast-forward 5 minutes. |
Cleanly reusable in Vitest tests via the same FakeClock; no production code path differs; cleanly excluded from prod builds via build-time check. | URL flag must be respected only in dev/test contexts (Vite's import.meta.env.DEV). |
| Hidden dev hotkey | ?+fast keyboard combo advances FakeClock |
Useful for manual playtest. | Doesn't compose with Playwright; still need the URL flag. |
| Sim-tick-count manipulation | E2E test directly mutates appStore.getState().lastTickAt |
Avoids the FakeClock entirely. | Bypasses the whole scheduler boundary; risk of false-positive test results if scheduler has bugs. |
Recommendation: Implement the URL flag + FakeClock injection (option ⭐). The FakeClock already exists in src/sim/scheduler/clock.ts for Vitest tests; the URL flag merely chooses the production wallClock vs the dev FakeClock at boot. Phase 2's PIPE-07 spec ships with this mechanism. Add a guard: in import.meta.env.PROD builds, the URL flag is silently ignored (the wallClock is always used).
Security Domain
security_enforcement is not explicitly false in .planning/config.json, so this section is included.
Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---|---|---|
| V2 Authentication | no | No accounts in v1 (single-player local). |
| V3 Session Management | no | Same. |
| V4 Access Control | no | Same. |
| V5 Input Validation | yes | All save imports go through SaveEnvelopeSchema (Zod) and the 50MB cap (already shipped Phase 1). All Ink-runtime variable injection is type-checked at the JS boundary; no string concatenation into Ink source at runtime. |
| V6 Cryptography | partial (integrity only) | CRC-32 checksum on save envelopes (already shipped Phase 1) detects corruption, NOT adversarial tampering. Phase 2 inherits this policy — single-player save tampering is by-design acceptable per src/save/envelope.ts SaveCorruptError doc comment. |
| V7 Error Handling and Logging | partial | Toast surfaces persistence-result respectfully (D-30); no PII in logs (none collected in v1). |
| V8 Data Protection | yes | No telemetry in Phase 2; saves are local-only; Base64 export is user-initiated and contains no secrets. |
| V12 Files and Resources | yes | validate-assets.mjs already shipped Phase 1; no new asset surfaces in Phase 2 (D-26 = primitives only). |
Known Threat Patterns for Phase 2 Stack
| Pattern | STRIDE | Standard Mitigation |
|---|---|---|
| Save tampering (player edits Roothold via DevTools) | Tampering | Accepted — single-player; CRC-32 detects accidental corruption only. Documented in src/save/envelope.ts (already Phase 1). |
| Malformed Base64 import (DoS via giant inflated string) | Denial-of-service | 50MB cap before lz-string decompression (already Phase 1). |
| System-clock manipulation to skip Lura beats | Tampering | Beats gate on tick count (harvest events), not wall time (STRY-10). FakeClock advance ≠ beat advance unless harvests also fire. |
Date.now() returns negative delta (clock-rewind cheat) |
Tampering | Scheduler refuses negative deltas (CORE-11); state does not advance; logged once. |
| AudioContext blocked → muted experience that misleads about audio failures | Information disclosure (sort of) / UX | Bootstrap function returns null gracefully; Phase 2 has no audio anyway. |
| Cross-origin script injection via Ink content | XSS | Ink content is repo-controlled (no user-authored Ink); inkjs renders to string and React renders strings, not HTML. No dangerouslySetInnerHTML. |
| Storage eviction silently wiping save | Tampering / data loss | navigator.storage.persist() request (CORE-05, already Phase 1) + LocalStorage fallback + Base64 export; soft toast respects user agency (D-30). |
Project Constraints (from CLAUDE.md)
These directives have the same authority as locked decisions and constrain Phase 2 plans:
- Stack is locked, do not re-litigate: Phaser 4, React 19, Zustand 5, break_eternity.js (via BigQty), Ink + inkjs, Howler.js, IndexedDB + LocalStorage + lz-string + versioned schema + Base64 export, Markdown+frontmatter / YAML / .ink in
/content/, Vitest + Playwright. Phase 2 plans must use these; alternatives are out of scope unless explicitly requested. - Architectural firewall (load-bearing): Phaser owns the canvas; React 19 owns the UI shell; Zustand bridges them; simulation core (
src/sim/) does not import fromsrc/render/orsrc/ui/. Enforced byeslint.config.jsboundaries/element-typesrule. Plans that introduce sim→render or sim→ui edges fail CI. - TypeScript strict; no
anyin 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 byFragmentSchemaregex. - Simulation modules are pure — no
Date.now(), nosetInterval, 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
BigQtywrapper aroundbreak_eternity.js. Never rawDecimalvalues in app code. This is exactly D-31 + Pattern 2. - Save format always carries
{schemaVersion, payload, checksum}. Never serialize raw state. Already shipped bysrc/save/envelope.tsPhase 1. - New AI-generated assets must carry full provenance metadata and pass the curation gate. Phase 2 ships zero AI-generated assets (D-26 = primitives), so the gate is not exercised; remains green.
- Anti-FOMO doctrine must be consulted at every UX decision. Phase 2's UX decisions all comply: no daily login bonus, no streaks, no limited-time content, no nag notifications, no loss-aversion copy, no countdown timers in core UI, persistence-denied is a soft in-voice toast (not nag).
- Banner concerns 1–10 (CLAUDE.md): all carry forward. Banner concern #1 (story ends but loop doesn't) is constrained by Phase 2's growth/economy choices not foreclosing Season 7's finite ceiling — confirmed by Pattern 7 (
V1Payloadextension, no Roothold pre-allocation; Phase 4 owns the prestige machinery). - Tone: Player-facing copy "warm, specific, intermittent, sometimes funny, sometimes devastating." Lura is the warmth anchor — write her as the contrast, not a co-griever.
- GSD config: Mode=YOLO (auto-approve gates), Granularity=Standard (5–8 phases), Plans run in parallel within a phase, Quality model profile, research+plan-check+verifier all on,
nyquist_validation: true. Plans use the Validation Architecture section above.
MVP Slice Proposal
Phase 2's mode is mvp (vertical-slice planning). Each plan should deliver an end-to-end thin slice rather than a horizontal layer. Recommended 5 plans across 3 waves:
Wave 0: Foundations (1 plan, blocks everything else)
Plan 02-01 — Foundations: BigQty + Zustand store + Tick scheduler
- Install
zustand@^5andbreak_eternity.js@^2.1.3. - Author
src/sim/numbers/big-qty.ts+format.tswith full Vitest coverage (UX-11 surface). - Author
src/sim/scheduler/clock.ts,tick.ts,catchup.tswith 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
V1Payloadper D-34 (D-19'sofflineEvents,unlockedPlantTypes,luraBeatProgress,settings.persistenceToastShown). - Add
src/save/lifecycle.test.tscovering UX-10 trigger points. - Wire the scheduler into
Gardenscene (tiny placeholder scene) so the live tick boots end-to-end. - Adds
src/game/event-bus.tsfor the Phaser EventBus singleton (per Phaser 4 template pattern). - Verifies
npm run cigreen.
Output: A running scaffold where the sim ticks, the store updates, the save schema is extended, and the firewall holds. Nothing player-visible yet — but Wave 1 plans can build features without re-running Wave 0's infrastructure.
Wave 1: Two parallel vertical slices (2 plans)
Plan 02-02 — Vertical slice: Begin + Plant + Grow
- Authors Season-1 plant types (2–3) in
src/sim/garden/plants.tswith 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.yamland a few long-form.mdper the existing convention. - Implements
src/sim/memory/{selector,pool}.ts(MEMR-06). - Implements
src/sim/garden/commands.tsharvest+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.tsandscripts/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:inkno-op stub withscripts/compile-ink.mjsinvoking theinklecatepackage. - 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(OfflineEventBlockzod 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=fakeFakeClock 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 cigreen standalone. No plan leaves CI red mid-phase.
Sources
Primary (HIGH confidence)
.planning/phases/02-season-1-vertical-slice-soil/02-CONTEXT.md— User decisions D-01 through D-34..planning/REQUIREMENTS.md— 24 Phase 2 REQ-IDs verbatim..planning/PROJECT.md— story bible synthesis, hard thematic constraints..planning/ROADMAP.md— Phase 2 success criteria (5)..planning/STATE.md— Phase 1 verification table (16/16 PASS)..planning/anti-fomo-doctrine.md— 17 banned UX patterns + review checklist..planning/season-7-end-state.md— principle-level rest-state contract..planning/phases/01-foundations-and-doctrine/01-CONTEXT.md— Phase 1 D-01..D-12 (save format, content pipeline, firewall locks)..planning/research/STACK.md— locked stack rationale, version compatibility, content pipeline shape..planning/research/ARCHITECTURE.md— three-layer firewall, tick scheduler shape, six architectural patterns..planning/research/PITFALLS.md— 14 critical pitfalls (esp. #1, #4, #6, #7, #11, #12).CLAUDE.md— stack lock, architectural firewall, banner concerns 1–10, code style.src/save/index.ts,src/save/migrations.ts,src/save/envelope.ts,src/save/codec.ts,src/save/db.ts,src/save/db-localstorage-adapter.ts,src/save/persist.ts,src/save/snapshots.ts— Phase 1 save layer (frozen).src/content/index.ts,src/content/loader.ts,src/content/schemas/{fragment,season,index}.ts— Phase 1 content pipeline.src/game/main.ts,src/game/scenes/Boot.ts— Phaser entry.src/App.tsx,src/PhaserGame.tsx— React shell + Phaser bridge.eslint.config.js— flat config +eslint-plugin-boundarieselement types.package.json— exact dependencies + scripts (verified 2026-05-09).vitest.config.ts,playwright.config.ts,vite.config.ts— test + build infrastructure.content/README.md— content authoring conventions.content/seasons/00-demo/fragments.yaml— demo to be removed in Phase 2.node_modules/inkjs/ink.d.mts+node_modules/inkjs/README.md— inkjs API verification.node_modules/inklecate/README.md+node_modules/inklecate/package.json— inklecate npm wrapper API.
Secondary (MEDIUM confidence — verified via WebSearch + cross-referenced with Primary)
- Phaser 4 React template EventBus pattern (May 2025 announcement, prepared for Phaser 4) — cross-referenced with
src/PhaserGame.tsx's template structure. - Phaser input documentation — pointerdown on interactive game objects
- Phaser EventEmitter
- Zustand 5 createStore (vanilla)
- Zustand vanilla store usage outside React
- Vite import.meta.glob lazy code-splitting — confirmed default-lazy when
eageris omitted/false. - Web Audio Autoplay Policy + AudioContext.resume()
- MDN Autoplay guide
- Fix Your Timestep — Gaffer On Games
- break_eternity.js (Patashu) — TypeScript-native, ships
index.d.ts. - inkjs README — getting/setting variables
- npm registry verification (
npm view <pkg> version) for phaser, zustand, break_eternity.js, inkjs, howler, inklecate (2026-05-09).
Tertiary (LOW confidence — flagged for verification during planning)
- The exact tile→DOM coordinate mapping under
Phaser.Scale.FITletterboxing (Assumption A5) — verify in Plan 02-02. - The
inklecatenpm wrapper's behavior on Windows with theinputFilepath/outputFilepathargument 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.mdand align with the existing scaffolded structure ineslint.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.jsis TypeScript-native + drop-in; the wrapper surface is small and obvious. - Zustand-Phaser bridge: HIGH — exact pattern is the official template's;
src/PhaserGame.tsxis already shaped for it. - Ink integration: MEDIUM —
inkjsandinklecateare 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)