From a5dc54e95f4fae7a753cec65dde2bef468412d3a Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 8 May 2026 21:23:58 -0400 Subject: [PATCH] docs: project research (stack, features, architecture, pitfalls) --- .planning/research/ARCHITECTURE.md | 815 +++++++++++++++++++++++++++++ .planning/research/FEATURES.md | 312 +++++++++++ .planning/research/PITFALLS.md | 568 ++++++++++++++++++++ .planning/research/STACK.md | 241 +++++++++ 4 files changed, 1936 insertions(+) create mode 100644 .planning/research/ARCHITECTURE.md create mode 100644 .planning/research/FEATURES.md create mode 100644 .planning/research/PITFALLS.md create mode 100644 .planning/research/STACK.md diff --git a/.planning/research/ARCHITECTURE.md b/.planning/research/ARCHITECTURE.md new file mode 100644 index 0000000..e6afb4c --- /dev/null +++ b/.planning/research/ARCHITECTURE.md @@ -0,0 +1,815 @@ +# Architecture Research + +**Domain:** Browser-based narrative idle game (7-Season prestige cycle, authored content-heavy, content + simulation + narrative trifecta) +**Researched:** 2026-05-08 +**Confidence:** HIGH (idle-game patterns are well-documented; narrative-engine integration patterns are well-documented; the *combination* is opinionated extrapolation marked MEDIUM where applicable) + +## Architectural Thesis + +The Last Garden is three games stacked in a trenchcoat: **(1)** an idle simulation with offline catch-up, **(2)** a content-heavy narrative game with authored fragments and dialogue arcs, **(3)** a stylized rendered surface with watercolor particles and ambient audio. The architecture's job is to keep these three concerns from colonizing each other. Specifically: + +1. **The simulation must be deterministic, headless, and renderer-agnostic.** It runs at a fixed tickrate, takes a delta-time, and produces state. It does not know about Pixi, the DOM, or audio. This is the precondition for offline catch-up math, save/load, replay testing, and (later) Web Worker offload. +2. **All authored content lives outside code.** Memory fragments, dialogue arcs, Season scripts, place-memory vignettes, and tuning numbers are JSON/YAML data files compiled into a runtime-optimized bundle. Writers should never need to touch a `.ts` file to add a fragment, and the build should reject content that fails schema validation. +3. **The renderer is a view onto state.** It subscribes to simulation snapshots and animates between them. It is replaceable. If we ever port to Steam/Tauri or rebuild the visuals, the simulation and content survive. + +This three-layer split (simulation → content → presentation) is what makes a 7-Season multi-year scope tractable. Everything else is a consequence. + +## System Overview + +``` +┌───────────────────────────────────────────────────────────────────────────┐ +│ PRESENTATION LAYER │ +│ (Reactive — subscribes to state, renders, never mutates simulation) │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Render │ │ UI / HUD │ │ Audio │ │ Story │ │ +│ │ (PixiJS) │ │ (React/Vue) │ │ (Howler) │ │ Cutscene │ │ +│ │ Garden, │ │ Inspector, │ │ Cello theme, │ │ Player │ │ +│ │ particles, │ │ journal, │ │ ambient │ │ (Ink runtime │ │ +│ │ animations │ │ shop, modals │ │ layers, Pale │ │ over modal) │ │ +│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ +│ │ │ │ │ │ +│ │ subscribe │ subscribe │ subscribe │ │ +│ └──────────────────┴────────┬─────────┴──────────────────┘ │ +│ │ │ +├─────────────────────────────────────┼───────────────────────────────────────┤ +│ ▼ │ +│ APPLICATION LAYER │ +│ ┌───────────────────────────────────────────────┐ │ +│ │ Game Store (Zustand) │ │ +│ │ Single source of truth for UI binding │ │ +│ │ Receives snapshots from simulation │ │ +│ │ Dispatches commands → simulation │ │ +│ └────────────────────┬──────────────────────────┘ │ +│ │ │ +│ ┌────────────────────▼──────────────────────────┐ │ +│ │ Command Bus / Event Bus │ │ +│ │ plant(), harvest(), prestige(), buy() │ │ +│ │ → events: harvested, fragmentUnlocked, ... │ │ +│ └────────────────────┬──────────────────────────┘ │ +├──────────────────────────────┼──────────────────────────────────────────────┤ +│ ▼ │ +│ SIMULATION CORE │ +│ (Pure, deterministic, headless. No DOM, no Pixi, no fetch.) │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Garden Sim │ │ Economy │ │ Season / │ │ Memory │ │ +│ │ Plants, │ │ Resources, │ │ Prestige │ │ Fragment │ │ +│ │ growth, │ │ rates, │ │ State machine│ │ Selector │ │ +│ │ pollination, │ │ BigNumber │ │ migrations, │ │ (rolls from │ │ +│ │ ecosystems │ │ math │ │ Roothold │ │ pool, gates) │ │ +│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ +│ └──────────────────┴─────────┬────────┴──────────────────┘ │ +│ │ │ +│ ┌───────────────▼────────────────┐ │ +│ │ Tick Scheduler (fixed dt) │ │ +│ │ + Offline Catch-up Engine │ │ +│ └────────────────────────────────┘ │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ DATA / PERSISTENCE LAYER │ +│ ┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────┐ │ +│ │ Content Repository │ │ Save Repository │ │ Telemetry Sink │ │ +│ │ (read-only, bundled) │ │ (versioned, IndexedDB│ │ (privacy-respect)│ │ +│ │ Fragments, dialogue, │ │ + localStorage │ │ → Plausible / │ │ +│ │ tuning, Seasons │ │ fallback, migration │ │ PostHog │ │ +│ │ → loaded at boot │ │ pipeline) │ │ │ │ +│ └──────────────────────┘ └──────────────────────┘ └──────────────────┘ │ +│ ┌──────────────────────┐ ┌──────────────────────────────────────────┐ │ +│ │ Asset Manifest │ │ Monetization / Entitlements │ │ +│ │ (sprites, audio, │ │ (Stripe-backed; server-authoritative; │ │ +│ │ CDN-hosted) │ │ cosmetic + Journal + acceleration) │ │ +│ └──────────────────────┘ └──────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────┘ + + Content Pipeline (build-time, not runtime) + ┌──────────────────────────────────────────────────────────────────┐ + │ Authored sources (YAML/Markdown/Ink) → schema validation │ + │ → compile to runtime JSON → bundle into Content Repository │ + └──────────────────────────────────────────────────────────────────┘ +``` + +The arrows are direction of *call/subscribe*. Data flow is downward through commands, upward through events and snapshot subscriptions. The simulation core never reaches up. The presentation layer never reaches down past the store. + +## Component Responsibilities + +### Simulation Core (Pure, Headless) + +| Component | Responsibility | Implementation | +|-----------|---------------|----------------| +| **Tick Scheduler** | Drives the fixed-timestep loop using the accumulator pattern. Owns `simulate(state, dtMs) → state`. The same function is used for live ticks and offline catch-up. | TypeScript class. `requestAnimationFrame` calls into it from main thread; later wrap in Web Worker. | +| **Offline Catch-up Engine** | Computes elapsed time since last save, divides into capped chunks, calls `simulate()` repeatedly. Caps at e.g. 24h to prevent infinite-progress death and to preserve idle-genre design constraint. | Pure function over saved state + wall-clock delta. | +| **Garden Sim** | Owns plant entities: position, species, growth stage, age, pollination links, ecosystem membership. Advances biology each tick. Knows nothing about graphics. | Data-oriented (struct-of-arrays for plants when count grows; object-of-structs for v1). | +| **Economy** | Resources, rates, multipliers, prestige curves. All "the numbers go up" math. Uses `break_eternity.js` for any value that can exceed `Number.MAX_SAFE_INTEGER`. | TypeScript module of pure functions over state slices. | +| **Season / Prestige State Machine** | Tracks current Season (1–7), beat within Season, prestige eligibility, Roothold preservation across resets, save migration on Season transitions. | Explicit state machine (xstate or hand-rolled). Deterministic transitions. | +| **Memory Fragment Selector** | Given the current Season + garden state + already-unlocked-fragments, selects the next fragment to grant on harvest. Honors authoring constraints (Season gates, prerequisites). | Weighted pool sampler reading from Content Repository. | +| **Story Trigger Engine** | Watches simulation events, evaluates trigger conditions ("first composted plant in Season 2," "sapling becomes canopy tree"), enqueues cutscene IDs into the application layer. | Rule table loaded from content; pure evaluator. | + +**Why pure/headless:** This is non-negotiable for an idle game. You need to be able to run `simulate()` 86,400 times in a row to model a day of offline play. If `simulate()` touches the DOM or schedules a particle effect, you're dead in the water. Pure functions also enable: deterministic replays for QA, save-state regression tests, time-travel debugging, and a clean Web Worker boundary later. + +### Application Layer (Glue) + +| Component | Responsibility | Implementation | +|-----------|---------------|----------------| +| **Game Store** | The reactive store that UI binds to. Receives state snapshots from the simulation each tick (or on change). Surfaces selectors. **Does not own logic** — it's a projection. | Zustand (lightweight, framework-agnostic, plays well with React or Vue). | +| **Command Bus** | Validates and dispatches user actions: `plant(speciesId, plotId)`, `harvest(plantId)`, `prestige()`, `buyCosmetic(sku)`. Each command is a pure intent that the simulation processes on next tick. | Tiny TypeScript module, no library needed. | +| **Event Bus** | The simulation emits domain events (`plantHarvested`, `fragmentUnlocked`, `seasonAdvanced`, `compostStarted`). Audio, story, telemetry, and UI subscribe. Decouples emitters from consumers. | `mitt` or hand-rolled `EventTarget`. | +| **Story Cutscene Player** | Pulls dialogue from Ink runtime (or Yarn Spinner JS), drives a modal/inspector overlay with character portraits, advances on click. Exposes a "playing cutscene" flag that pauses the simulation if needed. | Ink JS runtime is the recommended choice — battle-tested, Inkle's own engine, JSON-compiled output. | + +### Presentation Layer (View) + +| Component | Responsibility | Implementation | +|-----------|---------------|----------------| +| **Render** | The garden as PixiJS scene. Plant sprites, soil layers, pollination lines, particle systems for memory effects/Pale wash, watercolor post-processing via fragment shader. Reads from store, renders. | PixiJS v8. Custom shader for watercolor wash. `@pixi/particle-emitter` for memory motes. | +| **UI / HUD** | HUD, garden inspector, fragment journal, cosmetic shop, settings, save slots, accessibility menu. DOM-based (not Pixi text), so it's accessible, copy-pasteable, screen-reader-friendly. | React (recommended for talent/library availability) overlay on top of Pixi canvas. Tailwind for styling. | +| **Audio** | Music bus (cello theme), ambient bus (garden, thinning toward Pale), SFX bus (planting, harvesting, fragment-unlock chime). Crossfades on Season transitions. | Howler.js. Wraps Web Audio with HTML5 Audio fallback. | + +### Data / Persistence Layer + +| Component | Responsibility | Implementation | +|-----------|---------------|----------------| +| **Content Repository** | Load, validate, and serve all authored content: fragments, dialogue, Season scripts, plant species data, tuning numbers. Read-only at runtime. | JSON bundles compiled at build time. Schema-validated with Zod. Versioned. | +| **Save Repository** | Persist game state. Write debounced. Migrate on schema version bump. Multiple slots. Export/import to text blob (idle-game players value this hard). | IndexedDB primary (capacity, async, structured). localStorage fallback for quick UI prefs. Save format: `{ schemaVersion, payload, checksum }`. | +| **Asset Manifest** | Maps logical asset IDs → CDN URLs. Hashed filenames for cache-busting. Lazy-loaded per Season to keep first-paint small. | Build-emitted JSON. Vite handles hashing. | +| **Telemetry Sink** | Privacy-respecting event stream: install, Season reached, prestige completed, fragment unlock counts (aggregate), churn signals. No PII, no IDs that survive a uninstall. | Plausible (custom events) or PostHog (more analysis power). Plausible is the cozier-genre fit; PostHog if we want funnels. | +| **Entitlements / Monetization** | Owns "what has this player paid for." Server-authoritative (a tiny Cloudflare Worker / Supabase Edge Function backed by Stripe webhooks). Client treats the entitlement payload as read-only. | Stripe Checkout for purchases. Lightweight backend (Cloudflare Worker + KV / Supabase) for entitlements. JWT in localStorage. | + +### Content Pipeline (Build-Time, Not Runtime) + +| Component | Responsibility | Implementation | +|-----------|---------------|----------------| +| **Authoring Sources** | YAML for fragments and tuning (writer-friendly); Ink files for dialogue (industry-standard); Markdown for long-form vignettes. | Files in `content/` directory, version-controlled. | +| **Schema Validator** | Zod schemas describing every content shape. Build fails on invalid content. Editor support via JSON Schema export. | Zod + a build script. | +| **Compiler** | Transforms authoring formats into runtime-optimized JSON: Ink → compiled story JSON, YAML → typed JSON, Markdown → HTML strings with frontmatter. | Vite plugin or pre-build script. | +| **Bundler** | Splits content into per-Season chunks for lazy-loading. Generates the asset manifest. | Vite native code-splitting + a custom chunking strategy. | + +This pipeline is the load-bearing piece for a 7-Season scope. Without it, every fragment edit is a developer round-trip. + +## Recommended Project Structure + +``` +the-last-garden/ +├── content/ # AUTHORED, NOT CODE +│ ├── seasons/ +│ │ ├── 01-soil/ +│ │ │ ├── season.yaml # Beats, mechanics flags, palette +│ │ │ ├── fragments.yaml # Memory fragments unlockable in this Season +│ │ │ ├── dialogue.ink # Lura/Nameless Man/Archivist arcs +│ │ │ └── vignettes/ # Place-memory scenes (S3+) +│ │ ├── 02-roots/ +│ │ └── ... 03–07 +│ ├── plants/ +│ │ └── species.yaml # Real-world-but-slightly-wrong species data +│ ├── cosmetics/ +│ │ └── catalog.yaml # Planters, walls, gates, tool skins +│ └── tuning.yaml # Rates, multipliers, caps — single tuning file +│ +├── src/ +│ ├── sim/ # SIMULATION CORE — pure, headless +│ │ ├── tick.ts # Tick scheduler + accumulator +│ │ ├── catchup.ts # Offline catch-up engine +│ │ ├── garden/ +│ │ │ ├── plant.ts # Plant entity, growth stages +│ │ │ ├── pollination.ts +│ │ │ └── ecosystem.ts # S5+ ecosystem rules +│ │ ├── economy/ +│ │ │ ├── numbers.ts # break_eternity.js wrapper +│ │ │ ├── rates.ts +│ │ │ └── prestige.ts +│ │ ├── season/ +│ │ │ ├── machine.ts # State machine across 7 Seasons +│ │ │ ├── roothold.ts # Preserved-across-prestige currency +│ │ │ └── migrations.ts # State shape changes per Season +│ │ ├── memory/ +│ │ │ ├── selector.ts # Fragment pool sampling +│ │ │ └── journal.ts # Collected fragments state +│ │ ├── story/ +│ │ │ └── triggers.ts # Story trigger evaluator +│ │ └── state.ts # Root state shape; serialization +│ │ +│ ├── content/ # CONTENT LOADER — runs at boot +│ │ ├── schemas/ # Zod schemas +│ │ ├── loader.ts +│ │ └── compiled/ # GENERATED — gitignored; built from /content +│ │ +│ ├── app/ # APPLICATION GLUE +│ │ ├── store.ts # Zustand store +│ │ ├── commands.ts # Command dispatch +│ │ ├── events.ts # Event bus +│ │ ├── persistence/ +│ │ │ ├── save.ts # IndexedDB save/load +│ │ │ └── migrations.ts # Save schema migrations +│ │ ├── audio/ +│ │ │ ├── music.ts # Cello theme, season crossfade +│ │ │ ├── ambient.ts # Garden layer, Pale layer +│ │ │ └── sfx.ts +│ │ └── telemetry.ts +│ │ +│ ├── render/ # PRESENTATION — Pixi +│ │ ├── stage.ts # Pixi app bootstrap +│ │ ├── garden/ # Garden scene rendering +│ │ ├── particles/ # Memory motes, Pale wash +│ │ ├── shaders/ # Watercolor post-process +│ │ └── animations.ts # Tweens, growth animations +│ │ +│ ├── ui/ # PRESENTATION — DOM/React +│ │ ├── hud/ +│ │ ├── inspector/ +│ │ ├── journal/ # Fragment journal + Keeper's Journal premium +│ │ ├── shop/ +│ │ ├── settings/ +│ │ ├── cutscene/ # Modal overlay for dialogue +│ │ └── accessibility/ +│ │ +│ ├── story/ # NARRATIVE RUNTIME +│ │ ├── ink-runtime.ts # Ink JS wrapper +│ │ ├── cutscene-player.ts # Drives modal from Ink output +│ │ └── triggers-bridge.ts # Connects sim triggers → cutscene queue +│ │ +│ ├── monetization/ +│ │ ├── stripe.ts # Checkout redirect +│ │ ├── entitlements.ts # Read-only client view +│ │ └── shop-catalog.ts # Joins entitlements + cosmetics content +│ │ +│ └── main.ts # Composition root +│ +├── tools/ # BUILD-TIME CONTENT PIPELINE +│ ├── compile-content.ts # YAML/Ink → JSON +│ ├── validate-content.ts # Zod-based, fails build on errors +│ ├── season-bundle.ts # Per-Season chunking +│ └── tuning-report.ts # Optional: economy curve dumps for design +│ +├── server/ # MINIMAL BACKEND (Cloudflare Worker) +│ ├── stripe-webhook.ts +│ └── entitlements.ts +│ +├── tests/ +│ ├── sim/ # Pure-function unit tests, replay tests +│ ├── content/ # Content schema tests +│ └── e2e/ # Playwright smoke tests +│ +└── public/ # Static assets, CDN-uploaded at deploy +``` + +### Structure Rationale + +- **`content/` is at the repo root, not in `src/`.** This is intentional. Writers, designers, and (maybe) future contributors should be able to edit content without thinking about TypeScript. The build pipeline reaches into it; runtime code never imports from it directly. +- **`src/sim/` has no imports from anything else in `src/`.** This is the architectural firewall. Enforce it with an ESLint boundaries rule. If `sim/` ever imports from `render/` or `ui/`, the offline-catchup math breaks. +- **`src/render/` and `src/ui/` are both presentation but separated.** `render/` is Pixi (canvas). `ui/` is React DOM. They both subscribe to the same store. Splitting them makes accessibility and SEO much easier (DOM HUD is screen-reader-readable; canvas isn't). +- **`src/story/` is a runtime adapter, not authored content.** The Ink files live in `content/`. The runtime that plays them lives here. +- **`tools/` is build-time only.** Never shipped. This separation prevents accidental runtime imports of build-time logic. +- **`server/` exists only because monetization requires it.** Without entitlements being server-authoritative, the cosmetic shop is trivially exploitable. Keep it minimal — one Worker, one webhook, one entitlements lookup. + +## Architectural Patterns + +### Pattern 1: Pure Headless Simulation Core + +**What:** The simulation is a set of pure functions that take `(state, dt, commands) → (state', events)`. No side effects. No async. No DOM. No Pixi. + +**When to use:** Any game with offline progression, save/load, or a need for deterministic testing. This is universally applicable for idle games and is the single most important architectural decision. + +**Trade-offs:** +- **Pro:** Offline catch-up is just `simulate()` in a loop. Save/load is `JSON.stringify(state)`. Replay tests are trivial. Web Worker offload is a one-day refactor later. Determinism makes bug repros possible. +- **Con:** Forces discipline. New developers will want to "just put the particle effect call here" inside the sim. Enforce with linting and code review. + +**Example:** + +```typescript +// src/sim/tick.ts — pure, no I/O +export interface SimState { /* serializable game state */ } +export type Command = PlantCommand | HarvestCommand | PrestigeCommand /* ... */; +export type DomainEvent = HarvestedEvent | FragmentUnlockedEvent /* ... */; + +export interface SimResult { + state: SimState; + events: DomainEvent[]; +} + +export function simulate( + state: SimState, + dtMs: number, + pendingCommands: Command[], +): SimResult { + let next = state; + const events: DomainEvent[] = []; + + for (const cmd of pendingCommands) { + const r = applyCommand(next, cmd); // pure + next = r.state; + events.push(...r.events); + } + + const r = advanceTime(next, dtMs); // pure: growth, rates, triggers + next = r.state; + events.push(...r.events); + + return { state: next, events }; +} +``` + +The host (main thread, or eventually a Web Worker) calls `simulate()` and pushes events into the event bus. The host owns I/O. The sim owns logic. + +### Pattern 2: Fixed-Timestep Accumulator with Catch-up Cap + +**What:** The classic "Fix Your Timestep" pattern. Real time is deposited into an accumulator; the simulation runs in fixed-size chunks until the accumulator drains. For offline catch-up, the same fixed step is used but the elapsed time is capped (e.g., 24 hours) to prevent the spiral of death and to preserve idle-game design intent (returning players should be rewarded but not feel they "missed" infinite progress). + +**When to use:** Always for the simulation tick. Idle games specifically benefit because they want determinism (so save/load yields the same result) and offline catch-up (which is just running the same loop with a precomputed dt). + +**Trade-offs:** +- **Pro:** Determinism. Trivial offline math. Same code path for live and offline. Frame rate decoupled from sim rate. +- **Con:** "What tick rate?" matters. Too fast (60Hz) and offline catch-up of 24h = 5.2M ticks; will hang the browser. Too slow (1Hz) and pollination feels mushy. **Recommendation: 4–10 Hz simulation tick.** A day is then 345k–864k iterations, fast enough to catch up in <1s if `simulate()` is well-optimized, and granular enough that real-time feedback feels responsive. + +**Example:** + +```typescript +// src/sim/loop.ts +const TICK_MS = 200; // 5 Hz +const MAX_OFFLINE_HOURS = 24; + +export function runFrame(host: Host, now: number) { + const dt = now - host.lastFrameTime; + host.accumulator += dt; + while (host.accumulator >= TICK_MS) { + const result = simulate(host.state, TICK_MS, host.drainCommands()); + host.applyResult(result); // updates store, emits events + host.accumulator -= TICK_MS; + } + host.lastFrameTime = now; +} + +export function catchUpFromSave(host: Host, savedAt: number, now: number) { + const elapsedMs = Math.min(now - savedAt, MAX_OFFLINE_HOURS * 3600 * 1000); + const ticks = Math.floor(elapsedMs / TICK_MS); + for (let i = 0; i < ticks; i++) { + const result = simulate(host.state, TICK_MS, []); + host.applyResult(result, { silent: true }); // suppress audio/particles + } + // Then surface a "while you were away..." summary built from the silent events. +} +``` + +The `silent: true` flag is what prevents 200,000 plant-grew sound effects from queueing during offline catch-up. The events still get aggregated for the welcome-back summary; they just don't fire audio/particles. + +### Pattern 3: Content as Build Artifact, Not Code + +**What:** All authored content (fragments, dialogue, tuning, plant species, cosmetics) lives as YAML/Ink/Markdown in `content/`. A build step validates against Zod schemas, compiles into runtime JSON, and chunks per Season for lazy loading. The runtime imports only the compiled artifacts. + +**When to use:** Any content-heavy game. Mandatory for The Last Garden because the 7-Season scope means hundreds of fragments and dozens of dialogue arcs; touching code for each one would burn the project. + +**Trade-offs:** +- **Pro:** Writers can work in plain text. Schema enforces shape (no missing fields, no broken refs). Content can be hot-reloaded in dev. Per-Season chunking keeps initial bundle small. Sets up nicely for community modding later (mods are just additional content packs validated against the same schema). +- **Con:** Build complexity. Schema evolution is its own migration problem (handle by versioning each content type and supporting old versions during transitions). + +**Example:** + +```yaml +# content/seasons/03-canopy/fragments.yaml +- id: fragment.canopy.cathedral-window + season: 3 + unlock: + requires: [plant.species.oak.matured] + weight: 1.0 + text: | + Glass that thought in colors. The window above the altar showed + a story no one in the town remembered, but everyone agreed + was theirs. + archivist_response: fragment.canopy.cathedral-window.archivist +``` + +```typescript +// src/content/schemas/fragment.ts +export const FragmentSchema = z.object({ + id: z.string().regex(/^fragment\.[a-z0-9.-]+$/), + season: z.number().int().min(1).max(7), + unlock: z.object({ + requires: z.array(z.string()).optional(), + forbids: z.array(z.string()).optional(), + }), + weight: z.number().positive().default(1), + text: z.string().min(1), + archivist_response: z.string().optional(), +}); + +export type Fragment = z.infer; +``` + +Build fails on schema violation. CI runs the validator on every PR. + +### Pattern 4: Event-Sourced Simulation, Snapshot-Stored Persistence + +**What:** This is a hybrid. The simulation produces a stream of *events* each tick (`HarvestedEvent`, `FragmentUnlockedEvent`, etc.). Events drive audio, particles, story triggers, and telemetry. **But** persistence is a *snapshot* of the current state, not an event log. We don't replay 6 months of events on load — we save the world snapshot and resume from it. + +**Why this hybrid:** Pure event sourcing is overkill for a single-player browser game. Pure snapshot-only loses the rich event stream that makes the rest of the architecture cohere. The hybrid gives event-driven decoupling (audio/UI/story all subscribe) without event-store complexity. + +**When to use:** Whenever you want decoupled subscribers for "things that happened" but don't need full audit history. + +**Trade-offs:** +- **Pro:** Audio and particle systems trivially listen to `harvested` events without coupling to game logic. Story triggers are just event predicates. Telemetry is a tee of the event stream. Snapshot save is trivial. +- **Con:** Two concepts (events + state) to think about. Mitigate by having events be the *only* output of `simulate()` besides state. + +**Example:** + +```typescript +// Subscriber example — audio +eventBus.on('harvested', (e: HarvestedEvent) => { + if (e.silent) return; // skip during offline catch-up + audio.sfx.play('harvest-' + speciesOf(e.plantId)); +}); + +// Subscriber example — story triggers +eventBus.on('fragmentUnlocked', (e) => { + storyTriggers.evaluate(e); // may enqueue a cutscene +}); + +// Subscriber example — telemetry +eventBus.on('seasonAdvanced', (e) => { + telemetry.event('season_advanced', { to: e.season }); +}); +``` + +### Pattern 5: Save Versioning with Forward Migration Chain + +**What:** Every save includes a `schemaVersion: number`. On load, if the version is older than current, run migrations sequentially: `migrate_v1_to_v2`, `migrate_v2_to_v3`, etc. Each migration is a pure function. Save format also includes a checksum to detect corruption. + +**When to use:** Day one. A 7-Season game spans years. Save formats *will* change. Players will cherish their gardens. Losing saves is the unforgivable bug. + +**Trade-offs:** +- **Pro:** Old saves keep working forever. Migrations are testable in isolation. Easy to ship "it just works." +- **Con:** Migrations accumulate. Long-tail saves run more migrations. Mitigate by occasionally bumping a "minimum supported version" with explicit upgrade tools. + +**Example:** + +```typescript +// src/app/persistence/migrations.ts +type Migration = (s: unknown) => unknown; + +const migrations: Record = { + 2: (s: any) => ({ ...s, schemaVersion: 2, roothold: s.roothold ?? 0 }), + 3: (s: any) => ({ ...s, schemaVersion: 3, journal: s.journal ?? { entries: [] } }), + // 4: ... +}; + +export function migrate(saved: any, currentVersion: number) { + let s = saved; + while ((s.schemaVersion ?? 1) < currentVersion) { + const next = (s.schemaVersion ?? 1) + 1; + const fn = migrations[next]; + if (!fn) throw new Error(`No migration to v${next}`); + s = fn(s); + } + return s; +} +``` + +### Pattern 6: Server-Authoritative Entitlements, Client-Read-Only + +**What:** Cosmetics, Season acceleration, and the Keeper's Journal premium are paid features. The *client* asks the server "what does this user own?" on boot and treats the answer as read-only. Purchases go through Stripe Checkout; Stripe webhooks update entitlements; client refetches. + +**When to use:** Any monetization. Doing entitlements in localStorage means a browser DevTools console gives anyone everything for free. + +**Trade-offs:** +- **Pro:** Tamper-resistant. Cross-device when we add accounts. Standard pattern. +- **Con:** Requires a backend, however minimal. A Cloudflare Worker + KV (or Supabase) is enough. + +## Data Flow + +### User Action Flow (Online) + +``` +User clicks "plant rose" in UI + ↓ +ui/inspector → commands.dispatch(plant({species: 'rose', plot: 4})) + ↓ +Command queued for next tick + ↓ +Tick scheduler fires (every 200ms) + ↓ +simulate(state, dt, [plantCommand]) → { state', events } + ↓ +Store updated with state' (UI re-renders reactively) + ↓ +events fanned out: + ├── audio.sfx.play('plant') + ├── render emits a soil-puff particle + ├── storyTriggers.evaluate(planted) → maybe enqueue cutscene + └── telemetry.event('planted', {species: 'rose'}) + ↓ +Save Repository debounced-writes after N seconds of no changes +``` + +### Boot / Resume Flow + +``` +Page load + ↓ +Load Content Repository (compiled JSON, lazy per-Season) + ↓ +Load entitlements from server (cosmetics, Journal, acceleration owned) + ↓ +Load save from IndexedDB (fallback localStorage) + ↓ +Migrate save: migrate(saved, CURRENT_SCHEMA_VERSION) + ↓ +Validate save: schema check + checksum + ↓ +Compute offline elapsed: now - save.savedAt (capped at 24h) + ↓ +Run catchUp(state, elapsedMs, silent=true) + ↓ +Aggregate "while you were away" events into a summary + ↓ +Boot UI; show summary modal; enter live tick loop +``` + +### Story Trigger Flow (Cutscene Pause) + +``` +Simulation tick emits 'first-canopy-tree-matured' event + ↓ +storyTriggers evaluator matches → enqueues 'lura.canopy.intro' cutscene + ↓ +Cutscene player pulls dialogue from compiled Ink runtime + ↓ +Cutscene player flips store flag: simulationPaused = true + ↓ +Tick scheduler skips simulate() while flag is set (but still drives store/render) + ↓ +Player advances dialogue → cutscene player resumes simulation + ↓ +Save flagged dirty; debounced write +``` + +### Save / Load Round Trip + +``` +Save (every N seconds or on significant events): + state → JSON.stringify → checksum → wrap with schemaVersion + → IndexedDB transaction + → on quota error: prompt user + fall back to localStorage + → on success: emit 'saved' for UI confirmation + +Load: + IndexedDB read → parse → checksum verify → migrate → schema validate + → catchUp → boot +``` + +### Key Data Flows Summarized + +1. **Command flow (downward):** User action → store dispatch → command queue → simulation processes on next tick. +2. **State flow (upward):** Simulation produces snapshot → store updated → reactive UI/render re-renders. +3. **Event flow (lateral):** Simulation emits domain events → bus → audio + particles + story + telemetry subscribers. +4. **Content flow (boot-time only):** Authored YAML/Ink → build pipeline → compiled JSON → runtime Content Repository → consumed by simulation (via selectors), story (via Ink runtime), and UI (via shop catalog, journal display). +5. **Persistence flow (continuous):** State → debounced save → IndexedDB. Boot pulls save → migrate → catch-up → live. + +## Build Order / Phase Implications + +Architecture imposes natural dependencies. The roadmap should respect this order: + +| Order | Phase Topic | Why It Comes First | Dependencies | +|-------|------------|-------------------|--------------| +| 1 | **Project skeleton + TypeScript + Vite + content pipeline scaffold** | Without the content pipeline, every later phase is blocked. Establish `src/sim/` ↔ `src/render/` firewall now. | None | +| 2 | **Simulation core: tick loop, fixed timestep, deterministic state** | Everything sits on this. Build a vertical slice of "plant a rose, watch it grow" with pure functions. | Phase 1 | +| 3 | **Save/load with versioning + offline catch-up** | Idle games are unplayable without offline progression. Get this right early; it's hard to retrofit. | Phase 2 | +| 4 | **Content schema + first Season fragments + fragment selector** | Validates the content pipeline end-to-end. Memory fragments are the soul of the game; prove the loop early. | Phases 1, 2 | +| 5 | **Render layer (Pixi) + minimal UI (React) + store binding** | Now we have something visible. Watercolor shader can come later; flat sprites first. | Phases 2, 4 | +| 6 | **Audio (Howler) + ambient layers + Pale silence treatment** | Audio is mood-critical for this game but technically independent. Slot it in once the core loop is visible. | Phase 5 | +| 7 | **Story runtime (Ink) + cutscene player + first dialogue arc** | Requires content pipeline + UI overlay. Lura's first appearance proves the narrative pipeline. | Phases 4, 5 | +| 8 | **Season state machine + prestige + Roothold persistence** | First major test of save migration: prestige changes state shape. | Phases 3, 4 | +| 9 | **Watercolor shader + particle systems for memory effects** | Visual polish layer. Don't block earlier phases on this. | Phase 5 | +| 10 | **Seasons 2–7 content authoring (parallel with engine work after Phase 8)** | Content production is its own track once the pipeline is proven. | Phase 8 | +| 11 | **Monetization: backend + Stripe + entitlements + cosmetic shop** | Independent system. Can be added late since cosmetics don't affect core loop. | Phase 5 | +| 12 | **Telemetry, accessibility audit, performance optimization, Web Worker offload (optional)** | Polish, validate, ship. | All prior | + +**Critical build-order observations:** + +- **Phase 3 (save/load) before Phase 5 (render).** Counterintuitive, but you don't want to discover save bugs after you've built three Seasons of art. The simulation can be validated headlessly with replay tests. +- **Phase 4 (content pipeline) before Phase 5 (render).** Same reason. The content pipeline is load-bearing; if its schema is wrong, every Season's content needs rework. +- **Phase 8 (Season state machine + prestige) is the architectural stress test.** It's where save migration, content lazy-loading, and the simulation state machine all collide. Plan for it to take longer than expected. +- **Phases 10 and 11 are parallelizable.** Once the engine is proven through Season 1, content authoring for Seasons 2–7 and monetization integration are independent tracks. + +## State Management Approach: Justification + +**Recommendation: Centralized snapshot store (Zustand) bound to a pure simulation, with event-sourced subscribers, and snapshot-based persistence.** + +### Why not pure ECS? + +ECS shines for hundreds-to-thousands of entities with cache-friendly iteration (RTS, bullet hells, simulations like Factorio). The Last Garden has tens of plants per garden, dozens at most in late Seasons. ECS's complexity tax (component pools, archetypes, query systems) buys nothing here. Use plain typed objects/arrays. + +### Why not pure event sourcing? + +Pure event sourcing means persistence is the event log; current state is a projection. For a 7-Season game where a single player accumulates years of events, replaying them on load is a non-starter. Snapshot persistence is the right choice. + +### Why centralized store, not local component state? + +Idle games have radically asynchronous state shape. The HUD, the garden inspector, the journal, the shop, and the story player all need to react to plant growth, fragment unlocks, Season transitions, and prestige. Lifting all that to a single store and binding via selectors is dramatically simpler than threading props or relying on context. + +### Why Zustand specifically? + +- **Framework-agnostic.** If we ever swap React for Vue, Solid, or vanilla, Zustand works (it has tiny shims). React-specific stores like Recoil lock us in. +- **Tiny.** ~1KB. Doesn't compete with the renderer for memory. +- **No boilerplate.** Redux Toolkit is fine but heavy for our needs. We don't have a backend with cache invalidation; we have a simulation that produces snapshots. +- **Plays well with selectors.** Stable references, fine-grained subscriptions; UI doesn't re-render unnecessarily. + +### Idle-game-specific concerns this approach handles + +- **Offline catch-up:** Simulation is pure; loop it 86,400 times in catch-up mode with `silent: true` flag. +- **Save migration:** State is one well-defined shape. Migrations are pure transforms. +- **Determinism:** Pure simulation + ordered command queue + integer timekeeping = reproducible. +- **Story integration:** Events flow out of the sim; Ink runtime sits adjacent and is triggered by event subscribers without touching sim internals. + +Confidence on this recommendation: **HIGH** for the broad shape (pure sim + reactive store + event bus + snapshot save), **MEDIUM** for the specific library choice (Zustand vs Pinia vs hand-rolled — depends on Phase 1 framework choice; but the recommendation holds across them). + +## Content vs. Code Separation + +This is non-negotiable. The Last Garden has narrative content as its product. Treat content as a first-class build artifact: + +1. **Authored format** in human-friendly source (YAML for fragments, Ink for dialogue, Markdown for vignettes). Lives in `/content`. +2. **Schema validation** with Zod at build time. Build fails on invalid content. +3. **Compiled artifacts** (per-Season JSON bundles) consumed at runtime. Lazy-loaded. +4. **No content imports in `src/sim/`.** The simulation accepts content via injection (constructor param, factory function), not direct import. Makes the sim testable without loading the full content tree. +5. **Tuning lives in content too.** All multipliers, rates, costs in `content/tuning.yaml`. Designers can tune without touching code. +6. **Versioned content.** Each authoring file has an implicit content schema version (in the schema itself). Schema changes are migrations — generally non-breaking (additive), but tracked. + +This separation is the single most important investment for a multi-year, content-heavy scope. Skipping it is the most common idle-game mistake. + +## Modding / Extensibility + +**Recommendation: Architect for it; don't ship it in v1.** + +The data-driven content pipeline naturally produces a modding surface — a "mod" is just an additional content pack that conforms to the same Zod schemas. To leave the door open without paying the v1 cost: + +1. **Keep schemas stable and public.** Document them. Mods will eventually reference them. +2. **Make the content loader pluggable.** Replace `loadCompiledContent()` with `loadCompiledContent(sources: ContentSource[])` so additional sources can be merged at boot. +3. **Enforce content addressing by IDs, not array indices.** Mods that add fragments shouldn't break unlock chains. +4. **Don't expose a scripting API in v1.** Code-mods (Lua, JS) explode the support surface and have security implications in a browser context. Content-only mods are safe. +5. **Reserve `mod.*` ID prefixes** for community content to avoid collision with first-party. + +If modding becomes a stated post-1.0 goal, the work is mostly: package format, mod manager UI, manifest validation, and signing. The architecture supports it natively because of the content-as-data discipline. + +Confidence: **MEDIUM**. Modding for browser idle games is rare in the wild, so this is "leave the door open without paying for it." Don't promise it. + +## Scaling Considerations + +This is a single-player browser game with a tiny backend for entitlements. Scaling concerns are different from a multiplayer game. Here are the realistic bottlenecks in order: + +| Scale | What changes | +|-------|--------------| +| 0–1k DAU | Single Cloudflare Worker + KV. Plausible free tier. CDN via Vite output to Netlify/Cloudflare Pages. Save in IndexedDB locally. | +| 1k–100k DAU | Same architecture, scaled regions. Stripe handles its own scaling. Add error monitoring (Sentry). Bundle audit; per-Season chunks become more important. | +| 100k–1M DAU | Cloud sync becomes valuable (currently a stretch goal). Add account system + cloud save (Supabase Postgres or similar). CDN egress is the main cost. Telemetry needs sampling. | +| 1M+ DAU | If we get here, the game made it. Hire someone. | + +### Performance Bottlenecks (in likely order of appearance) + +1. **Simulation tick cost when many plants exist (Season 5+ ecosystems).** Mitigation: profile per-tick cost; if exceeding ~5ms on a mid-range device, move sim to a Web Worker (the architecture supports this — sim is already pure and headless). +2. **Initial bundle size from Pixi + content + audio assets.** Mitigation: lazy-load per-Season content and audio. Watercolor sprites should be authored at moderate resolution, not 4K. +3. **IndexedDB write contention if save is too aggressive.** Mitigation: debounce save (every 5s of no changes, or on significant events). +4. **Particle counts during Memory Storms (Season 4+).** Mitigation: cap particle pool size; reuse instances. PixiJS particle emitter handles this natively. +5. **Garbage collection pauses** if the simulation allocates objects per tick. Mitigation: prefer mutating state in place inside `simulate` (still pure from outside); reuse arrays. + +### Scaling Priorities + +1. **First bottleneck: simulation tick under heavy plant load.** Fix: profile, tighten hot paths, then Web Worker offload. +2. **Second bottleneck: bundle size on first paint.** Fix: per-Season lazy loading, asset CDN. +3. **Third bottleneck: cloud save egress (if we ship cloud sync).** Fix: delta saves rather than full state, or compress with `pako`. + +## Anti-Patterns + +### Anti-Pattern 1: Letting the Renderer Drive the Simulation + +**What people do:** Use `requestAnimationFrame` to call `simulate()` directly with the wall-clock dt. The simulation's tick rate is the frame rate. + +**Why it's wrong:** +- Simulation is non-deterministic (a 60fps player and a 144fps player get different results). +- Offline catch-up becomes ad-hoc math instead of "just run the loop." +- Save/load can desync depending on which frame the save fired. + +**Do this instead:** Fixed-timestep accumulator. Render at whatever frame rate; sim runs in fixed chunks. + +### Anti-Pattern 2: Authored Content in Code Files + +**What people do:** Hard-code memory fragments in TypeScript arrays. Hard-code dialogue in template literals. Hard-code tuning numbers in constants. + +**Why it's wrong:** +- Writers and designers can't contribute without merging code. +- Tuning iteration is gated on rebuilds. +- The content-vs-code firewall collapses; presentation logic creeps into content files. +- Localization becomes a nightmare. + +**Do this instead:** YAML/Ink/Markdown in `/content`, Zod-validated, compiled at build time, lazy-loaded per Season. + +### Anti-Pattern 3: Storing Game State in DOM/UI Component Local State + +**What people do:** Resource counters live in a React `useState` in the HUD component. Plant lists live in the inspector component. + +**Why it's wrong:** +- Save/load is impossible without coordinating dozens of components. +- Simulation can't update state without prop-drilling or context-pyramid. +- Logic and view get tangled. +- Component unmount loses game state. + +**Do this instead:** All persistent state in the simulation/store. UI components are projections. + +### Anti-Pattern 4: Storing Entitlements in localStorage + +**What people do:** `localStorage.setItem('hasJournal', 'true')` after a successful purchase. + +**Why it's wrong:** +- DevTools makes everything free. +- Cross-device fails as soon as we add accounts. +- Refunds and chargebacks have no enforcement mechanism. + +**Do this instead:** Server-authoritative entitlements via Stripe webhooks. Client treats the response as read-only and refetches on app start. + +### Anti-Pattern 5: Treating Story Triggers as One-Off Conditional Code + +**What people do:** Sprinkle `if (justHarvested && season === 2 && !hasSeenLuraIntro) playCutscene('lura-intro')` throughout the codebase. + +**Why it's wrong:** +- Triggers scatter across files; hard to audit. +- Designers can't author new triggers without code changes. +- Refactoring sim internals breaks story. + +**Do this instead:** Story triggers as a *data table* loaded from content. A single evaluator runs against domain events. Adding a new beat is editing a YAML file. + +### Anti-Pattern 6: Rendering Game UI Inside the Pixi Canvas + +**What people do:** Build buttons, modals, and the journal inside the Pixi scene graph using Pixi text and rectangles. + +**Why it's wrong:** +- Accessibility (screen readers, copy/paste, keyboard nav) is gone. +- Localization is harder. +- Forms, scroll lists, and rich text are reinventing the DOM badly. + +**Do this instead:** Pixi for the *garden* (the diegetic world). DOM/React for the HUD, inspector, journal, shop, modals. They overlay the canvas. + +### Anti-Pattern 7: Naive Offline Catch-up (Multiply Resources by Time) + +**What people do:** `resources += rate * elapsedSeconds` and call it done. + +**Why it's wrong:** +- Misses non-linear interactions (pollination, ecosystem cross-effects, fragment unlocks at thresholds). +- Misses Season transitions that should trigger during catch-up. +- Story triggers don't fire; players come back missing beats. + +**Do this instead:** Run the actual `simulate()` loop, capped at a max offline window, with `silent: true` to suppress per-tick audio/particles. Aggregate events into a summary screen. + +## Integration Points + +### External Services + +| Service | Integration Pattern | Notes | +|---------|---------------------|-------| +| **Stripe** | Stripe Checkout (hosted) for purchases; webhooks → server endpoint → entitlements DB. | Hosted Checkout avoids PCI scope. Always reconcile via webhook, never trust client confirmation. | +| **Cloudflare Workers + KV (or Supabase)** | Backend for entitlements lookup. JWT or anonymous-but-signed token issued at install. | Keep tiny. One Worker, one KV namespace. Stretch: cloud save. | +| **Plausible (or PostHog)** | Custom events via JS SDK. Cookieless. No PII. | Plausible recommended for cozy/narrative tone — minimal, ethical. PostHog if funnels are needed. | +| **CDN (Cloudflare Pages / Netlify)** | Static asset hosting + edge cache. Vite output uploaded at deploy. | Hashed filenames for cache-busting. Per-Season chunks. | +| **Sentry (post-launch)** | Error reporting. Breadcrumbs from event bus help reproduce. | Sample aggressively. Don't leak save data. | + +### Internal Boundaries + +| Boundary | Communication | Notes | +|----------|---------------|-------| +| Sim ↔ Application Layer | Commands in (queued); state snapshots and events out. | Enforced by ESLint boundaries. Sim has no imports from other `src/` folders. | +| Application ↔ Render | One-way reactive subscription via store selectors. | Render never dispatches commands directly to sim — goes through store/command bus. | +| Application ↔ UI (DOM/React) | Same store. UI dispatches user actions as commands. | Both Render and UI bind to the same store; consistency is automatic. | +| Application ↔ Story Runtime | Story consumes events from event bus; sets `simulationPaused` flag in store; reads dialogue from compiled Ink content. | Story has no direct hooks into sim; it's a peer subscriber. | +| Application ↔ Audio | Audio is purely reactive: subscribes to events, fires Howler. No state owned. | Music transitions can be smarter (Season-aware) but state-of-truth is the store. | +| Application ↔ Persistence | Save Repository serializes the sim state; debounced on changes. | Migrations live here, not in the sim. Sim sees only current-version state. | +| Application ↔ Monetization | Entitlements fetched at boot; injected into store. UI reads owned entitlements; cosmetic application is a render concern. | Cosmetics affect rendering only; never gate gameplay/narrative. | +| Build-time Content ↔ Runtime Content Repository | One-way. Build emits compiled JSON. Runtime never writes content. | Modding (post-1.0) extends loader to merge additional sources. | + +## Sources + +- [The Idle Game Illusion: How Delta-Time Powers Progress](https://www.geekextreme.com/idle-games-offline-progression-math/) — offline catch-up via retrospective delta-time. +- [Fix Your Timestep! — Gaffer On Games](https://gafferongames.com/post/fix_your_timestep/) — fixed-timestep accumulator pattern; spiral of death prevention. +- [Performant Game Loops in JavaScript — Aleksandr Hovhannisyan](https://www.aleksandrhovhannisyan.com/blog/javascript-game-loop/) — JS-specific game loop implementation. +- [How to make a game loop for your idle game — gist](https://gist.github.com/HipHopHuman/3e9b4a94b30ac9387d9a99ef2d29eb1a) — idle-game-specific loop implementation. +- [Anatomy of a video game — MDN](https://developer.mozilla.org/en-US/docs/Games/Anatomy) — browser game loop with Web Workers. +- [Use web workers to run JavaScript off the browser's main thread — web.dev](https://web.dev/articles/off-main-thread) — Web Worker offload patterns. +- [Multithreaded game example — kirbysayshi](https://github.com/kirbysayshi/multithreaded-game-example) — sim-in-Worker, render-on-main pattern. +- [Engine Internals: Content Pipeline — Heinäpurola](https://medium.com/@heinapurola/engine-internals-content-pipeline-1af34a117f1) — build-time vs runtime separation. +- [break_eternity.js](https://github.com/Patashu/break_eternity.js) — incremental-game large-number library. +- [break_infinity.js](https://github.com/Patashu/break_infinity.js) — predecessor; speed-prioritized. +- [Save/Load System — shapez.io](https://deepwiki.com/tobspr-games/shapez.io/2.2-saveload-system) — real-world idle-adjacent save/migration implementation. +- [Using IndexedDB — MDN](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB) — IndexedDB schema versioning via `onupgradeneeded`. +- [Managing database migrations in IndexedDB](https://colinchjs.github.io/2023-10-01/18-36-30-229308-managing-database-migrations-in-indexeddb/) — migration patterns. +- [Game Save Best Practices — Bugnet](https://bugnet.io/blog/game-save-best-practices-construct3) — versioning, slots, error handling. +- [Yarn Spinner](https://yarnspinner.dev/) — narrative scripting language. +- [Inkle Ink](https://www.inklestudios.com/ink/) — narrative scripting language with JS runtime. +- [Twine vs Yarn vs Ink comparison](https://narrativeflow.dev/blog/twine-vs-yarn-spinner-vs-ink-vs-narrativeflow-which-branching-dialogue-tool-is-right-for-your-game/) — tool comparison for branching dialogue. +- [Howler.js](https://howlerjs.com/) — Web Audio with HTML5 fallback; fade/crossfade. +- [PixiJS](https://pixijs.com/) — 2D WebGL renderer; particles; shaders. +- [A Gentle Introduction to Shaders with Pixi.js](https://www.awwwards.com/a-gentle-introduction-to-shaders-with-pixi-js.html) — Pixi shader basics. +- [Phaser vs PixiJS comparison](https://generalistprogrammer.com/comparisons/phaser-vs-pixijs) — framework vs renderer trade-off. +- [Zustand — pmndrs](https://github.com/pmndrs/zustand) — minimal centralized store. +- [Zustand vs Redux Toolkit (2025)](https://isitdev.com/zustand-vs-redux-toolkit-2025/) — current-state state-library comparison. +- [Event Sourcing — Martin Fowler](https://martinfowler.com/eaaDev/EventSourcing.html) — canonical event-sourcing reference. +- [Eventsourcing: State from Events or Events as State? — Verraes](https://verraes.net/2019/08/eventsourcing-state-from-events-vs-events-as-state/) — when to use which. +- [ECS for Unity](https://unity.com/ecs) and [ecs-faq — SanderMertens](https://github.com/SanderMertens/ecs-faq) — ECS rationale and when it's overkill. +- [Gaming Monetization — Stripe](https://stripe.com/resources/more/gaming-monetization-explained) — cosmetic monetization patterns. +- [Marketplace monetization with Stripe](https://stripe.dev/blog/marketplace-monetization-with-stripe) — webhooks → entitlements pattern. +- [Plausible vs PostHog comparison](https://posthog.com/blog/posthog-vs-plausible) — privacy analytics options. +- [Power of Data-Driven Architecture — DEV](https://dev.to/tomokat/power-of-data-driven-architecture-applies-to-game-development-as-well-6m0) — data-driven content for moddability. +- [How to make a game moddable](https://www.gameslearningsociety.org/how-to-make-a-game-moddable/) — modding architecture principles. + +--- +*Architecture research for: browser-based narrative idle game (The Last Garden)* +*Researched: 2026-05-08* diff --git a/.planning/research/FEATURES.md b/.planning/research/FEATURES.md new file mode 100644 index 0000000..d2bca38 --- /dev/null +++ b/.planning/research/FEATURES.md @@ -0,0 +1,312 @@ +# Feature Research + +**Domain:** Browser-based narrative idle game (cozy + narrative + idle Venn) — *The Last Garden* +**Researched:** 2026-05-08 +**Confidence:** HIGH (genre conventions are well-documented; tonal alignment to PROJECT.md is the load-bearing judgment call) + +--- + +## Feature Landscape + +### Table Stakes (Users Expect These) + +Features that, if missing, cause idle game players to bounce or write angry reviews. These are non-negotiable. Players don't praise their presence — they punish their absence. + +| Feature | Why Expected | Complexity | Notes | +|---------|--------------|------------|-------| +| **Offline progression simulation** | Idle games are *defined* by the promise that the world advances when you're not playing. Without it, the genre contract is broken. | HIGH | Must simulate elapsed time on resume. Cap matters (typically 8–24 hours) — players resent both "lost" idle time and unbounded numbers. For The Last Garden, the cap *is* a tonal lever (the garden waits, but not forever). | +| **"What you missed while away" report** | Players need to feel rewarded on return, not lost. Industry pattern: a modal/screen showing fragments earned, plants grown, narrative beats reached. | MEDIUM | Tonal opportunity: not a spreadsheet but a small letter from the garden. "While you were away, the moonbloom opened. Lura sat with it." Drives Day-2 retention. | +| **Save persistence (local)** | Idle games run for weeks/months. Losing a save = losing the relationship. Browser idle games *must* survive tab closes, restarts, accidental clears. | MEDIUM | Use IndexedDB primary (50MB+, async, survives storage pressure better than localStorage), with localStorage as fallback. Auto-save every 10–30s. Multiple save slots / rolling backups. Hard requirement per PROJECT.md. | +| **Save export / import (manual backup)** | Standard practice in browser idle games because browsers wipe storage. Lets users back up, move between devices, recover from corruption, and submit bug reports. | LOW | Base64-encoded text blob. Copy/paste UI (clipboard via fallback overlay for any embedded WebGL contexts). "Always export before importing" warning is genre-standard. | +| **Manual save / load slot UI** | Defensive players want explicit control over their long-running save. Auto-save alone breeds anxiety. | LOW | 2–3 slots minimum. Show date, season, key stat. New game = explicit confirmation, never automatic. | +| **Audio toggles (music + SFX + ambient, separate sliders)** | Idle games run in background tabs for hours. A single mute is insufficient — players keep ambient on, mute music, etc. | LOW | Three-channel mixer minimum. Persisted across sessions. Especially important for The Last Garden because cello/silence are part of the storytelling. | +| **Master mute / quick mute keybind** | Idle games are played at work, in meetings, while watching TV. Instant mute is muscle memory. | LOW | M key or icon. Visual confirmation. Survives reload. | +| **Pause / resume** | Cozy/contemplative players want to "set down" the game without feeling time pressure. Even more important when the loop is metaphor. | LOW | True pause (no time advance) vs. afk pause. Both have a place; document the choice. | +| **Settings menu (audio, visual, accessibility, save management)** | Standardized expectation. No settings = unprofessional. | LOW | Single discoverable gear icon. Group sensibly. | +| **Notification of meaningful events** | Long idle gaps mean players miss state changes. They need a cue (browser tab title flash, in-game flag, or PWA push). | MEDIUM | Title bar update is the cheapest, most respectful pattern. Push notifications are higher-impact but require service worker, opt-in, and risk feeling intrusive in a cozy game. | +| **Tab visibility-aware behavior** | Players keep the tab open in background. Game must continue (or simulate continuation on focus return) without burning CPU/battery. | MEDIUM | requestAnimationFrame paused when hidden, fall back to deltaTime calculation on focus. | +| **Multi-buy / max-affordable purchase** | Once costs scale exponentially, single-click purchasing becomes hand-cramping busywork. Genre-standard since Cookie Clicker. | LOW | Buy x1 / x10 / x100 / Max toggle. "Max" calculates the largest affordable batch. UI: a small toggle next to the buy button. | +| **Numeric formatting (K, M, B, scientific notation)** | Idle game numbers blow past human-readable scales. Without formatting, the UI becomes unreadable. | LOW | Standard library: scientific notation past 1e15, named suffixes (K/M/B/T) below. The Last Garden's economy may stay smaller-scale per its tone — still need this. | +| **Visible progress / upgrade tree / what's next hints** | Players need to know what they're working toward. Pure mystery loses players; pure roadmap kills wonder. | MEDIUM | The Last Garden's challenge: convey "something is coming" without spoiling Season transitions. Soft cues (a leaf appears at the wall edge) > explicit progress bars. | +| **Achievement / collection tracking** | Persistent positive feedback. In narrative idle, doubles as a reading log of fragments collected. | MEDIUM | Maps directly to the memory fragment collection in The Last Garden. The Keeper's Journal is essentially this feature, gated as premium. A free baseline collection view is table stakes. | +| **First-time-user experience (FTUE) that doesn't break tone** | First 30 min determines if players stay. Idle games have a unique FTUE problem: there's "nothing to do" until something appears. | HIGH | A Dark Room solves this with a single button and patience. Universal Paperclips trusts the player. The Last Garden should follow this lineage — minimal explicit instruction, lots of careful first-action design. | +| **Resume-from-pause friendliness** | Cozy/idle players close the tab for a week and return. The game must orient them: where am I, what's new, what should I do? | MEDIUM | Returning-player view: time-since indicator, "what happened" recap, gentle "next" affordance. | +| **Accessibility: keyboard navigation** | Industry baseline. Web players assume tab/enter works. | MEDIUM | Most idle games are click-only and fail this. Don't. | +| **Accessibility: text scaling / readable contrast** | Browser players come from many eyesight contexts. Mandatory under modern web norms. | LOW | Respect browser zoom. Minimum AA contrast on all text/UI. | +| **Accessibility: reduced motion option** | Watercolor + animation may include effects (wind, growth, particles) that trigger motion sensitivity. WCAG 2.1 baseline. | LOW | Respect `prefers-reduced-motion`. Provide explicit toggle. | +| **Accessibility: colorblind support / icon redundancy** | Don't convey state by color alone. Especially important if Season palettes shift heavily. | LOW | Pair color cues with shape/icon. The seven Seasons each have distinct palettes — already an opportunity to enforce icon-redundant identity per Season. | +| **Tab title / favicon as background indicator** | When tab is in background, players want a glance signal that something's ready. | LOW | Update document.title with a count or symbol when meaningful events accumulate. Cheap, expected, respected. | +| **Stable performance with tab in background** | Tab throttling (browsers throttle setInterval in background tabs) is a known foot-gun. Players will rage if numbers stop moving. | MEDIUM | Use timestamps + simulation on resume rather than per-tick increments. This is the standard idle-game safe pattern. | +| **No data loss on page refresh** | One accidental F5 cannot wipe progress. | LOW | Frequent autosave + onbeforeunload save. | +| **Privacy-respecting telemetry (or none)** | Cozy/narrative audience overlaps heavily with privacy-conscious indie game audience. Heavy telemetry feels off-brand. | LOW | If included: opt-in, transparent, anonymized. Often "off by default" is the right call for this audience. | + +### Differentiators (Competitive Advantage) + +These are where The Last Garden competes — features that elevate it above the crowded idle-RPG/clicker space and deliver on the cozy + narrative + idle promise. + +| Feature | Value Proposition | Complexity | Notes | +|---------|-------------------|------------|-------| +| **Mechanic-as-metaphor design (every system carries narrative weight)** | This *is* the project's Core Value per PROJECT.md. Composting = letting go. Prestige = grief. Roothold = what survives. No other idle game commits to this. | HIGH | Not a feature so much as a design discipline — but it's what distinguishes The Last Garden from every other idle. Every mechanic gets reviewed against "does this carry the metaphor." | +| **Season-specific mechanics (7 Seasons, each with distinct system)** | Unlike the typical idle that adds layers atop the same loop, each Season transforms the game. Soil → Roots → Canopy → Storm → Depth → Loom → Return. This is *Universal Paperclips*-style escalation, not Cookie Clicker stacking. | HIGH | The biggest production risk *and* the biggest creative differentiator. Each Season ships as a vertical slice of mechanics + art + audio + writing. | +| **Discovery-driven progression (no roadmap; reveal by play)** | Hallmark of A Dark Room and Paperclips. Players don't know what's coming — finding out *is* the game. This is the cozy-narrative-idle audience's primary delight. | MEDIUM | Tension with FTUE: players need *just enough* to not bounce. Lean on the A Dark Room rule: one button at start, expand only when the player is ready. | +| **Authored memory fragment system (every harvest = a piece of writing)** | Direct alignment to PROJECT.md core mechanic. Turns the idle harvest loop into a reading experience. The fragments *are* the world-building (no codex). | HIGH | Requires hundreds of authored fragments. Pipeline is content-pipeline-heavy, not engineering-heavy. Display: "while you were away" report doubles as fragment delivery. | +| **Watercolor + cello aesthetic (consistent through all 7 Seasons, evolving)** | Aesthetic is the moat. No idle game looks or sounds like this. Audience overlaps significantly with the *Spiritfarer* / *Stardew* / *Cozy Grove* art-direction-driven audience. | HIGH | AI-assisted art + hand-refinement pipeline (per PROJECT.md). Audio: solo cello + ambient. Tone-locked per Season. | +| **"Place-memory vignettes" (Season 3+) — short interactive scenes from canopy trees** | Breaks the idle loop with bursts of authored interactivity. Like *Spiritfarer*'s spirit storylines but short and lyrical. | HIGH | Scene-graph / dialogue system needed. Could ship as Season 3 content, doesn't have to be in v1's earliest playable build. | +| **Three-character authored arcs (Lura, Nameless Man, Archivist)** | Idle games rarely have characters with arcs. This is borrowed from cozy/narrative games (*Spiritfarer*, *Stardew*, *Disco Elysium*-light). Hard differentiator. | HIGH | Dialogue tree / scripting system. Characters appear in tonally-keyed beats per Season, not gated behind currencies. | +| **Roothold as never-lost prestige currency** | Most idle games' prestige currencies are about throughput (multipliers). Roothold represents *understanding*. It's a thematic choice that becomes a mechanical signature. | MEDIUM | Standard prestige math (Cookie Clicker / Paperclips style); the differentiator is thematic framing and what it unlocks (memory permanence, not number scale). | +| **Final binary narrative choice ("the garden persists" ending)** | Single-decision ending matches A Dark Room / Paperclips conviction. Players project themselves onto their gardens — the choice lands because of accumulated investment, not because of fork-tree-of-decisions complexity. | MEDIUM | One scene. Two outcomes. Both respect player investment. Locked by story bible. | +| **Tonal pacing across Seasons (palette + audio shift)** | Seasons aren't just mechanic shifts — they're emotional shifts. Golden/autumnal warmth → deep green/storm tension → dawn/silver release. Almost no idle game does this. | MEDIUM | Asset organization by Season. Cross-fade transitions. Per-Season ambient loops. | +| **Memory Storms (Season 4+ event mechanic)** | Idle games rarely have weather/event systems with narrative weight. Storms are both mechanic (resource influx + risk) and metaphor (memory rushes back, then leaves). | MEDIUM | Time-windowed events. Visual + audio cue. Can ship in a later Season patch. | +| **Cross-pollination + ecosystem planting (Season 5+)** | Idle games stack systems shallowly; this introduces a true depth curve in late Seasons. Differentiates from clicker/RPG flatness. | HIGH | Plant-relationship system. Compatibility / interaction matrix. Requires careful design to remain readable. | +| **Episodic Season patches as live content updates** | Hollow Knight / Hades model: ship base game with a complete arc, then drop free additive content patches that deepen specific Seasons or add new place-memory vignettes. Sustains community without exploiting it. | MEDIUM | Requires content pipeline + hot-patchable content data. Distinct from full-episodic release (which we're explicitly not doing — see Anti-Features). | +| **Cosmetic-only monetization (planters, walls, gates, tool skins) tied to garden aesthetic** | Per PROJECT.md. Animal Crossing-style decoration purchases that *reinforce* the aesthetic, not generic skins. Trust-building monetization. | MEDIUM | Item pipeline + cosmetic equip system. Content-light (a dozen items at launch is enough). | +| **Premium "Keeper's Journal" feature for annotating fragments** | One-time purchase or unlockable. Lets players annotate, organize, mark favorites, write their own observations next to fragments. Genuinely valuable feature, ethical pricing. | MEDIUM | Rich-text or markdown editing surface, persistent storage. The free version still shows fragments; the premium adds annotation layer. | +| **Season acceleration purchase (never skipping)** | Per PROJECT.md: a paid "lean forward" option for players short on time. Clear ethical line: accelerates the rate but never bypasses a story beat. | MEDIUM | Simple multiplier on Season-specific tick rate. Story gates still apply. | +| **Letter-style "while you were away" return screen** | Tonal differentiator. Most idle games show a stat dump on return; The Last Garden shows a small written note from Lura/the garden. | MEDIUM | Templated micro-prose generated from the events that occurred. Authored variants per Season. | +| **PWA / installable home screen presence** | Lets dedicated players "live with" the game without a tab. Aligns with cozy game aesthetics (Stardew on a phone home screen). | MEDIUM | Manifest + service worker. Optional push notification opt-in for Memory Storm events. | +| **"Quiet mode" / sleep mode for the garden** | Cozy player tells the game: "I'll be back in a week. Wait for me." The garden enters a stasis state. Tonally rhymes with the Pale's silence. | LOW | Narrative-dressed pause feature. Player consent for time-skip behavior. | + +### Anti-Features (Commonly Requested, Often Problematic) + +Features standard in the broader idle/F2P market that would directly undermine The Last Garden's thematic argument or cozy/narrative tone. The reasoning here ties to PROJECT.md's hard constraints — these aren't soft preferences, they're load-bearing creative decisions. + +| Feature | Why Requested | Why Problematic | Alternative | +|---------|---------------|-----------------|-------------| +| **Gacha / lootbox mechanics** | Industry-standard mobile idle monetization (54.7% of idle revenue is IAP). Highly effective at extraction. | Directly contradicts PROJECT.md's thematic argument: "the game's argument is that you cannot reduce complex things to simple transactions." Gacha is the embodiment of that reduction. Locked out by story bible. | Cosmetic catalog with fixed prices. Players see exactly what they're paying for. | +| **Combat / boss fights / enemy power creep loops** | Idle RPG is the genre's largest sub-segment (13.7% CAGR). The "kill numbers go up" loop is well-understood and lucrative. | The Archivist is not a boss. There is no enemy. PROJECT.md is explicit. Adding combat would shift the game out of the cozy-narrative-idle Venn entirely. | The "stakes" come from the Unremembering — environmental, not adversarial. Tension via loss, not opposition. | +| **Daily quest / login bonus pressure systems** | Industry-standard retention pattern. Forms habits, increases DAU. Players in idle communities tolerate it. | Active hostility to the cozy/contemplative audience. Cozy players play *when they want to*. Daily quests turn the game from a relationship into an obligation. Many cozy-game communities cite "no daily quest harassment" as a buying reason. | Persistent narrative pull (something always growing) instead of FOMO mechanics. Streaks would actively poison the metaphor. | +| **Energy / stamina systems** | Mobile F2P pattern that creates monetizable friction. | Energy systems exist to artificially gate play, then sell the gate. Anti-cozy. Anti-trust. | Time-gated growth (plants take real time) is the natural rhythm. No artificial stamina layer. | +| **Rewarded ads / ad-watch incentives** | 28.3% of idle game revenue. Easy money. | Disrupts atmosphere catastrophically. Cello + watercolor + Mountain Dew ad = tone collapse. Also requires partner SDK that complicates web build. | Cosmetic purchases. Premium Keeper's Journal. Season acceleration. All non-disruptive to atmosphere. | +| **Narrative content gated behind purchase** | Story-DLC monetization works in many genres. | Hard line per PROJECT.md: story is the product, story is never paid. This is the highest-trust commitment to the audience. | Cosmetics + utility (Keeper's Journal) + acceleration. Never the story itself. | +| **Skipping Seasons (vs accelerating)** | Players who already played want to "see the end" faster. | The cumulative weight of the seven Seasons *is* the experience. Skipping breaks the metaphor. Per PROJECT.md. | Acceleration purchase: same beats, faster ticks. Story preserved. | +| **Lore codex / encyclopedia / wiki-style entries** | Standard worldbuilding feature in narrative games. Players love feeling completionist about lore. | PROJECT.md: "World-building emerges through fragments only. The player should always feel like they're *almost* understanding." A codex would resolve the ambiguity that *is* the meaning. | Memory fragments only. Fragments are by nature incomplete. Resist the pull to make them tidy. | +| **Generic fantasy flora (D&D-style)** | Easier to source AI art for "moonbloom" than for "a hyacinth that's slightly wrong." | PROJECT.md: plants must be real species, slightly wrong. Generic fantasy plants would shift the visual language toward standard fantasy idle and lose the uncanny grounding. | Real-species reference + AI generation tuned to slight wrongness. Curation is heavier; the payoff is unique. | +| **Multiplayer / social / clan / leaderboard systems** | Drives long-tail retention. Universal in modern idle. | PROJECT.md: "This is a contemplative, solitary experience." Adding social features pollutes the introspective tone. Garden visiting *might* be considered post-1.0 if it preserves tone — but the bar is high. | Solitude is the feature. Lean into it. The Keeper is alone. | +| **Voiced dialogue / cutscenes (v1)** | Modern narrative game expectation. | PROJECT.md tone: "a friend texting you while you're at work." Voice acting at v1 cost is also production-prohibitive. Reconsider only if specific scenes benefit. | Text only, with cello and ambient as the soundscape. Silence does dialogue work. | +| **Always-online requirement / server-side validation** | Anti-cheat / anti-piracy. | The audience is browser indie players. Always-online breaks the "I can play this on a plane" expectation and adds infrastructure cost for no thematic gain. | Local-first save. Cloud sync as optional stretch. | +| **Aggressive notification / re-engagement push** | F2P industry standard ("we miss you!" emails, "your harvest is ready!" pings). | Cozy audience finds these intrusive. Erodes trust. | Opt-in push for Memory Storms only (rare, tonally appropriate events). Default off. Tab title is sufficient for routine signaling. | +| **Power-creep loops / multiplier escalation as primary motivator** | The bedrock pattern of clicker/idle. | Numbers in service of story (PROJECT.md), not the inverse. Pure power-creep would make the metaphors invisible. | Roothold-as-understanding, not Roothold-as-multiplier. Mechanical depth over numerical scale. | +| **Cosmetic items unrelated to the garden setting** | Easy revenue (skins of every flavor sell). | PROJECT.md: cosmetics must reinforce, not dilute, the aesthetic. A neon planter would break the watercolor world. | Every cosmetic is a planter, wall, gate, tool — all garden-coherent. Curated catalog over volume. | +| **A named/personality-rich Keeper character** | Players want to know who they are. | PROJECT.md: "The Keeper is a presence, not a personality." The player projects themselves onto the Keeper — that's the trick. | Keeper has presence (cursor, intent, the garden's care) but no portrait, no name, no dialogue beyond the final choice. | +| **In-game wiki / hint system / objective tracker** | Modern UX expectation in many genres. | Resolves the discovery loop that Paperclips/Dark Room teach the audience to *love*. Patronizes the player. | Trust the player. Lean on environmental cues. If a system needs a hint system to be understood, the system needs redesign. | +| **Time-skip purchases that bypass real-time growth** | "Speed up by 4 hours" is mobile-standard. | The waiting *is* the metaphor. Bypassing time bypasses the meaning. | Season acceleration adjusts the rate, doesn't compress real-time gates that carry weight. | +| **Generic UI chrome / corporate idle aesthetic** | Most idle games look like spreadsheets with skins. | The Last Garden's UI is part of the watercolor world. Hand-crafted UI that breathes the same air as the art. | Custom UI components. Watercolor-edged buttons. Hand-lettered numerics where it serves. | + +--- + +## Feature Dependencies + +``` +Save persistence (local) + └── enables ──> Offline progression simulation + └── enables ──> "What you missed" return screen + └── enables ──> Letter-style return narration + +Memory fragment system + └── requires ──> Save persistence + └── enables ──> Achievement / collection tracking + └── enables ──> Premium Keeper's Journal (annotation layer) + +Core idle loop (plant/wait/harvest) + └── enables ──> Roothold prestige currency + └── enables ──> Seasonal prestige cycle + └── enables ──> Season-specific mechanics (1–7) + └── enables ──> Place-memory vignettes (S3+) + └── enables ──> Memory Storms (S4+) + └── enables ──> Cross-pollination/ecosystem (S5+) + └── enables ──> Final binary choice (S7) + +Settings menu (audio/visual/accessibility) + └── required for ──> All accessibility features + └── required for ──> Audio toggles, reduced motion, etc. + +Cosmetic system + └── requires ──> Save persistence (to remember equipped items) + └── requires ──> Asset pipeline maturity + └── enables ──> Cosmetic monetization + +Episodic content patches (live updates) + └── requires ──> Hot-patchable content data layout + └── requires ──> Save schema versioning + migration + └── enables ──> Post-launch Season expansions / vignette additions + +PWA / installability + └── enables ──> Push notifications (opt-in) + └── enables ──> Home-screen presence + +Tab visibility-aware tick logic + └── required for ──> Stable performance with tab in background + └── required for ──> Offline progression accuracy on focus return +``` + +### Dependency Notes + +- **Save persistence is foundational.** Every long-running idle feature depends on it. Get this right first — schema migrations are painful retroactively, especially when 7 Seasons of content evolution will demand schema growth. +- **Memory fragments depend on save persistence + offline progression.** Players will want to read fragments from offline harvests; the "while you were away" report and the fragment delivery are the same surface. +- **Save schema versioning is required before episodic patches.** If post-launch content patches add new Season state, old saves must migrate cleanly. This is the kind of decision that wrecks an idle game retroactively. +- **Settings menu is required infrastructure for accessibility.** Don't ship without it — accessibility added late is twice the work. +- **The cosmetic system needs the asset pipeline to be mature.** Cosmetics aren't urgent, but they require curated assets — the same pipeline that produces Season art. Monetization is unlikely to be a v1.0 launch feature; v1.1+ is fine and more honest. +- **Tab visibility / background tick logic is a foot-gun.** Browsers throttle background tabs. The standard fix is timestamp-based simulation on focus, not interval-based ticks. This must be the default architecture, not retrofitted. +- **Discovery-driven progression conflicts with explicit objective tracker / hint system.** Don't ship both. The discovery loop is the differentiator; explicit hints kill it. + +--- + +## MVP Definition + +The Last Garden is committed to shipping all 7 Seasons at v1 (per PROJECT.md). That's the *content* commitment. But within that, there's a "smallest playable vertical" that proves the loop and tone before scaling content. + +### Launch With (v1.0) + +The shipped game. All seven Seasons of authored content + the table-stakes feature set. + +- [ ] **Core idle loop** — plant, wait, harvest memory fragments — *this is the entire game's foundation* +- [ ] **Offline progression simulation** with sensible cap — *genre table stakes* +- [ ] **"While you were away" return screen, in letter style** — *tonal differentiator on a table-stakes feature* +- [ ] **Save persistence (IndexedDB primary, localStorage fallback)** — *non-negotiable for long-running play* +- [ ] **Save export / import (Base64 text)** — *defensive UX for browser persistence reality* +- [ ] **Memory fragment system + fragment collection view** — *core narrative delivery vehicle* +- [ ] **All 7 Seasons of authored content** (Soil → Roots → Canopy → Storm → Depth → Loom → Return) — *PROJECT.md commitment* +- [ ] **Three character arcs (Lura, Nameless Man, Archivist)** — *PROJECT.md commitment* +- [ ] **Final binary narrative choice + "garden persists" ending** — *story-bible-locked* +- [ ] **Roothold prestige currency** — *story-bible-locked* +- [ ] **Settings menu** with audio sliders (music/SFX/ambient), reduced motion toggle, contrast/text-size respect — *accessibility baseline* +- [ ] **Master mute keybind + tab title indicator** — *idle-genre baseline* +- [ ] **Tab visibility-aware tick logic** — *required for correctness* +- [ ] **Multi-buy (x1/x10/x100/Max) for purchases that scale** — *idle baseline* +- [ ] **FTUE that respects the player** (one button at start, expand by reveal) — *competes on tone* +- [ ] **Watercolor + cello aesthetic, palette-shifted by Season** — *the moat* +- [ ] **Cross-pollination, composting, ecosystem planting (S5+) and Memory Storms (S4+)** — *PROJECT.md commitment* +- [ ] **Place-memory vignettes (S3+)** — *PROJECT.md commitment* + +### Add After Validation (v1.x) + +Features to ship as free patches once the base game is out and the audience is real. These are genuinely valuable but don't gate launch. + +- [ ] **PWA / installability** — *added when player demand for "live with it" is confirmed* +- [ ] **Opt-in push notifications for Memory Storm events** — *only after PWA exists; risk of feeling intrusive* +- [ ] **Cloud save sync** — *huge UX win, but infrastructure cost; ship after proving demand* +- [ ] **Cosmetic monetization catalog** — *requires asset pipeline maturity; don't launch with monetization, launch with the game* +- [ ] **Season acceleration purchase** — *genuine player value once the game is stable; tonally needs careful execution* +- [ ] **Premium Keeper's Journal (annotations layer)** — *deepens the relationship with fragments; ship once free baseline is loved* +- [ ] **Additional place-memory vignettes** — *episodic content patches, Hollow-Knight-style free additive content* +- [ ] **Achievements / collection milestones (beyond fragment count)** — *deepens completionist motivation, but secondary to the main loop* +- [ ] **Multiple save slots beyond the default 2–3** — *if requested* +- [ ] **Quiet/sleep mode (narrative-framed pause)** — *if players ask for it* + +### Future Consideration (v2+) + +Things to defer until product-market fit is established and the team has room. + +- [ ] **Steam port** — *post-1.0 per PROJECT.md* +- [ ] **Mobile port (iOS/Android native or PWA-as-app)** — *post-1.0 per PROJECT.md* +- [ ] **Garden visiting / sharing (limited social)** — *only if it preserves the contemplative tone; bar is high* +- [ ] **Localization beyond English** — *enormous content burden given fragment density; v2+ minimum* +- [ ] **Voiced moments (specific scenes, not full dialogue)** — *only if cello/silence soundscape benefits* +- [ ] **Modding / fragment authoring tools** — *community-extension dream, far-future* + +--- + +## Feature Prioritization Matrix + +| Feature | User Value | Implementation Cost | Priority | +|---------|------------|---------------------|----------| +| Core idle loop (plant/wait/harvest) | HIGH | MEDIUM | P1 | +| Save persistence (local) | HIGH | MEDIUM | P1 | +| Offline progression | HIGH | HIGH | P1 | +| "While you were away" letter | HIGH | MEDIUM | P1 | +| Memory fragment system | HIGH | HIGH | P1 | +| 7 Seasons of authored content | HIGH | VERY HIGH | P1 | +| Settings + audio toggles | HIGH | LOW | P1 | +| Reduced motion / accessibility baseline | MEDIUM | LOW | P1 | +| Save export/import | MEDIUM | LOW | P1 | +| Multi-buy / max-affordable | MEDIUM | LOW | P1 | +| Tab visibility-aware ticks | HIGH | MEDIUM | P1 | +| FTUE that respects the player | HIGH | MEDIUM | P1 | +| Roothold prestige | HIGH | MEDIUM | P1 | +| Three character arcs | HIGH | HIGH | P1 | +| Final binary choice | HIGH | LOW | P1 | +| Watercolor + cello aesthetic | HIGH | VERY HIGH | P1 | +| Place-memory vignettes (S3+) | HIGH | HIGH | P1 | +| Memory Storms (S4+) | HIGH | MEDIUM | P1 | +| Cross-pollination (S5+) | HIGH | HIGH | P1 | +| PWA installability | MEDIUM | MEDIUM | P2 | +| Cosmetic monetization | MEDIUM | MEDIUM | P2 | +| Premium Keeper's Journal | MEDIUM | MEDIUM | P2 | +| Season acceleration | MEDIUM | LOW | P2 | +| Cloud save sync | MEDIUM | HIGH | P2 | +| Push notifications (opt-in) | LOW | MEDIUM | P2 | +| Multiple save slots | LOW | LOW | P2 | +| Quiet/sleep mode | LOW | LOW | P3 | +| Achievement system (beyond fragments) | LOW | MEDIUM | P3 | +| Steam port | MEDIUM | HIGH | P3 | +| Mobile port | MEDIUM | HIGH | P3 | +| Localization | MEDIUM | VERY HIGH | P3 | +| Garden visiting (limited social) | LOW | HIGH | P3 | + +**Priority key:** +- P1: Must have for launch (v1.0) +- P2: Should have, ship as v1.x patch +- P3: Future consideration, defer + +--- + +## Competitor Feature Analysis + +| Feature | A Dark Room | Universal Paperclips | Cookie Clicker | Melvor Idle | Spiritfarer | Stardew Valley | The Last Garden Approach | +|---------|-------------|----------------------|----------------|-------------|-------------|----------------|--------------------------| +| **Offline progression** | Limited (event-driven) | No (active session) | Yes (capped) | Yes (full) | N/A (not idle) | N/A | Yes, capped, narratively framed | +| **"What you missed" report** | Random events on return | None | Stat dump | Stat dump | N/A | N/A | Letter-style narrative (differentiator) | +| **Tutorial / FTUE** | Single button reveal | Trust-the-player | Tooltip-light | Heavy tutorial | Hand-holding intro | Soft intro | A Dark Room lineage — discovery | +| **Save persistence** | localStorage | localStorage | localStorage + cloud | Local + cloud (Steam) | Steam cloud | Steam cloud | IndexedDB + export/import + optional cloud | +| **Save export/import** | Yes (text) | Yes (text) | Yes (text) | Yes (text) | No | No | Yes (Base64 text) | +| **Discovery vs roadmap** | Pure discovery | Pure discovery | Visible upgrades | Visible skill tree | Mixed | Mixed | Pure discovery (lineage commitment) | +| **Narrative depth** | Heavy (the twist) | Heavy (the existential arc) | Light (jokes) | Almost none | Heavy (the arcs) | Medium (NPCs) | Heavy (7 Seasons + 3 arcs) | +| **Prestige** | None (one-shot) | "New Universe" loop | Heavenly Chips | None (skill-based) | N/A | N/A | Roothold (never-lost prestige) | +| **Multi-buy** | N/A | N/A | x1/x10/x100/Max | x1/x10/x100/Max | N/A | N/A | x1/x10/x100/Max | +| **Aesthetic ambition** | Minimalist text | Minimalist UI | Cookies-and-jokes | Functional UI | High (hand-painted) | High (pixel art) | High (watercolor + cello) | +| **Monetization model** | Free + paid mobile port | Free | Free + paid mobile | One-time paid + DLC | Premium ($30) | Premium ($15) | Free base + cosmetics + acceleration + Keeper's Journal | +| **Daily quest pressure** | None | None | None | Optional events | None | None (festivals are voluntary) | None — explicit anti-feature | +| **Combat / enemies** | Yes (combat layer) | Yes (drone wars) | None | Yes (skill-based) | None | Light (mining) | None — explicit anti-feature | +| **Episodic content updates** | No (static) | No (static) | Yes (free patches) | Yes (paid DLC) | No | Yes (free patches) | Yes (free additive patches, Hollow Knight model) | +| **Multiplayer / social** | None | None | None (leaderboards on Steam) | None | None | Multiplayer (3.0+) | None v1; maybe garden-visiting v2+ | +| **Accessibility (color/motion)** | Minimal | Minimal | Minimal | Minimal | Strong | Strong | Strong (cozy-audience expectation) | + +**Key takeaway:** The Last Garden inherits A Dark Room and Paperclips' lineage on **discovery**, **narrative weight**, and **trust the player**, while inheriting Spiritfarer and Stardew's lineage on **aesthetic ambition**, **accessibility**, and **respectful monetization**. It uniquely combines these into the cozy + narrative + idle Venn that PROJECT.md identifies as uncontested. + +--- + +## Tonal-Tradeoff Notes (load-bearing for downstream design) + +Because PROJECT.md is unusually opinionated about tone, several normally-default features need explicit tonal review: + +1. **"What you missed" screen as letter, not stat dump.** The data is the same; the framing is the entire experience. +2. **Notifications opt-in and rare.** The push notification standard for idle games is aggressive. This game's contract with the player is gentler. Default off; opt-in for Memory Storms only. +3. **No streaks, no "don't break the chain."** Streaks are FOMO weaponized. The garden does not punish absence. +4. **Numbers should grow but not dominate the screen.** Most idle games center the count; The Last Garden centers the garden visualization, with numbers as a secondary read. +5. **Tutorialization is environmental.** No "click here" arrows. No popup overlays. The first plant teaches you what planting is. The Dark Room rule: one button, expand only when the player is ready. +6. **Settings menu chrome must match the world.** A standard browser-game settings overlay (gray rectangle, system fonts) would break atmosphere. Watercolor-edged components, garden-vocabulary labels where natural ("Quiet" not "Mute"), but not so cute it becomes opaque. +7. **Cosmetic items are catalog, not random drop.** Players see exactly what they're buying. No surprise, no FOMO. + +--- + +## Sources + +- **A Dark Room design philosophy:** [TV Tropes](https://tvtropes.org/pmwiki/pmwiki.php/VideoGame/ADarkRoom), [Beginners guide without spoilers](https://ber10thal.com/blog/the-unofficial-guide-to-a-dark-room-without-spoilers/), [Wikipedia](https://en.wikipedia.org/wiki/A_Dark_Room) +- **Universal Paperclips structure:** [Paperclips Wiki - Stages](https://universalpaperclips.fandom.com/wiki/Stages), [History of incremental games](https://medium.com/@touloutoumou/from-progress-quest-to-universal-paperclip-the-history-of-free-incremental-games-3c96bfeaa918) +- **Cookie Clicker prestige system:** [Cookie Clicker Wiki - Ascension](https://cookieclicker.fandom.com/wiki/Ascension), [Heavenly Chips](https://cookieclicker.fandom.com/wiki/Heavenly_Chips) +- **Idle game player expectations 2025/2026:** [Future of Idle Games](https://gamertagguru.com/blog/the-future-of-idle-games-trends-and-expectations), [Apptrove guide](https://apptrove.com/a-guide-to-idle-games/) +- **Save persistence patterns:** [IndexedDB game saves tutorial](https://app.cinevva.com/tutorials/indexeddb-game-saves), [LocalStorage vs IndexedDB](https://dev.to/tene/localstorage-vs-indexeddb-javascript-guide-storage-limits-best-practices-fl5), [localForage](https://blog.logrocket.com/localforage-managing-offline-browser-storage/), [Construct 3 game save best practices](https://bugnet.io/blog/game-save-best-practices-construct3) +- **Save export/import conventions:** [TV Tropes - Export Save](https://tvtropes.org/pmwiki/pmwiki.php/Main/ExportSave), [Salt Keep devlog on save import/export](https://smallgraygames.itch.io/the-salt-keep/devlog/485074/feature-design-save-import-export) +- **Cozy game progression and storytelling:** [Stardew vs Spiritfarer comparison](https://thegemsbok.com/art-reviews-and-articles/spiritfarer-stardew-valley-mechanics-comparison/), [Spiritfarer preview](https://gameinformer.com/preview/2020/07/30/why-spiritfarer-is-a-different-kind-of-sim), [Integrating storytelling in cozy games](https://sdlccorp.com/post/integrating-narrative-and-gameplay-how-storytelling-enhances-cozy-games/) +- **Cosmetic monetization ethics:** [Game Economist Consulting on cozy games](https://www.gameeconomistconsulting.com/here-come-the-cozy-games-so-what-now/), [Ethical monetization design](https://www.daydreamsoft.com/blog/ethical-monetization-system-design-earning-revenue-without-losing-player-trust), [Indie monetization ethics](https://www.wayline.io/blog/ethical-dilemmas-monetization-indie-games) +- **Animal Crossing customization model:** [Furniture customization Wiki](https://nookipedia.com/wiki/Furniture_customization), [Customization guide](https://animalcrossing.fandom.com/wiki/Furniture_customization) +- **PWA / push notifications for games:** [MDN: PWAs and push for js13kGames](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Tutorials/js13kGames/Re-engageable_Notifications_Push), [W3C games-on-the-web roadmap](https://w3c.github.io/web-roadmaps/games/lifecycle.html), [Web Game Dev: PWAs](https://www.webgamedev.com/publishing/pwa) +- **Episodic vs full release for narrative games:** [Episodic video games on Wikipedia](https://en.wikipedia.org/wiki/Episodic_video_game), [Black Tabby on episodic narrative](https://blacktabbygames.medium.com/how-early-access-can-work-for-episodic-narrative-games-a-midpoint-post-mortem-ab19796de5b0), [Vice on the death of episodic gaming](https://www.vice.com/en/article/why-did-episodic-gaming-die/) +- **Live content updates (Hollow Knight / Hades model):** [Hollow Knight DLC](https://hollowknight.wiki.fextralife.com/DLC), [Hades II patch model](https://steamcommunity.com/app/1145350/discussions/0/682987821011709628/) +- **FTUE / onboarding best practices:** [Antidote FTUE playbook](https://antidote.gg/ftue-the-antidote-playbook/), [Unity 10 FTUE tips](https://unity.com/how-to/10-first-time-user-experience-tips-games), [GameAnalytics F2P FTUE tips](https://www.gameanalytics.com/blog/tips-for-a-great-first-time-user-experience-ftue-in-f2p-games) +- **Accessibility patterns for games:** [Color-Blindness Accessibility Guide](https://caniplaythat.com/2020/01/29/color-blindness-accessibility-guide/), [Accessible Games - Distinguish This From That](https://accessible.games/accessible-player-experiences/access-patterns/distinguish-this-from-that/), [Filament Games - Color blindness](https://www.filamentgames.com/blog/color-blindness-accessibility-in-video-games/) +- **Daily-quest harassment dynamics:** [Resetera idle escalation thread](https://www.resetera.com/threads/what-idle-game-escalation-is-your-favorite.109498/), [Idle Slayer community discussion on weekly quest design](https://steamcommunity.com/app/1353300/discussions/0/5287797783031277967/) + +--- +*Feature research for: cozy + narrative + idle browser game (The Last Garden)* +*Researched: 2026-05-08* diff --git a/.planning/research/PITFALLS.md b/.planning/research/PITFALLS.md new file mode 100644 index 0000000..03e7bd1 --- /dev/null +++ b/.planning/research/PITFALLS.md @@ -0,0 +1,568 @@ +# Pitfalls Research + +**Domain:** Browser-based narrative idle game (cozy + narrative + idle Venn) with 7-Season authored arc, AI-assisted asset pipeline, solo/small-team production +**Researched:** 2026-05-08 +**Confidence:** HIGH for save/persistence/browser pitfalls (well-documented post-mortems and platform docs); HIGH for idle game balance pitfalls (codified knowledge in incremental-game community); MEDIUM-HIGH for narrative-idle pacing (genre is small, so evidence is small-N but specific); MEDIUM for AI-pipeline pitfalls (rapidly evolving practice, fewer post-mortems, but concrete failure modes documented); HIGH for cozy-tone pitfalls (well-discussed in genre criticism); HIGH for solo-scope failure (heavily documented). + +--- + +## Critical Pitfalls + +### Pitfall 1: The Story Ends but the Idle Loop Doesn't + +**What goes wrong:** +The authored 7-Season arc completes in ~30-50 hours of engaged play. The player is now staring at a Roothold counter incrementing forever, with no fragments left to harvest, no characters left to meet, and no reason to keep the tab open. The idle mechanic is now empty calories — Roothold growth that purchases nothing meaningful, with the narrative argument ("what survives is what you understood") feeling hollow because the game itself proves it false (your understanding stopped accumulating). Players churn, reviews go from "moving" to "padded out," and the lineage comparison to *A Dark Room* and *Universal Paperclips* (both of which **end**) becomes a critique rather than a positive association. + +**Why it happens:** +Idle games are conventionally engineered for indefinite engagement (months, years). Narrative games are conventionally engineered for a fixed run (10-100 hours). Designers porting idle mechanics into narrative games inherit the "infinite curve" instinct without realizing that *A Dark Room* and *Universal Paperclips* succeeded specifically because they end. The seven-Season structure is finite by design, but the temptation to add a "post-game" or "New Game+" or endless prestige tier dilutes the story's claim that **persistence is the point**. + +**How to avoid:** +- Decide *before* writing any economy code: does this game **end** like *A Dark Room*, or does it **rest** like a finished album you can replay? The Last Garden's premise ("what persists") strongly suggests "rest" — i.e., Season 7 (Return) reaches a stable, low-activity state where the garden continues but no longer demands attention. Make this an explicit design principle. +- Author the **end state** first (what does Season 7 Day 14 look like?) before authoring intermediate Seasons. This prevents the trap of designing for engagement-without-end. +- Cap exponential growth so Roothold has a meaningful ceiling tied to the narrative, not infinity. The story argument "Roothold is what you truly understood" implies it is **finite** — you can't understand infinitely many things. +- Build a "credits roll" / coda experience for players who reach the end. Not an empty grind. *Universal Paperclips* shows the credits and ends. + +**Warning signs:** +- Spreadsheets with prestige curves that extend past Season 7 +- Playtester feedback like "I finished the story, what now?" +- Roothold values that require BigNumber libraries by Season 5 (suggests you've over-scaled) +- Meetings where someone says "endgame content" — there is no endgame, the game ends + +**Phase to address:** +**Phase 1 (foundations / vertical slice).** Before any economy code lands, the design doc must specify the Season 7 end state and the "what after the credits" answer. This is a core-value pitfall — getting this wrong undermines the thematic argument the game exists to make. + +--- + +### Pitfall 2: Players Grind Past Story Beats + +**What goes wrong:** +A player accumulates Roothold faster than the story expects, prestiges into Season 4 with currency that lets them rocket to Season 5 in an evening, and never reads the Season 4 fragments. They emerge into the Memory Storm mechanic (Season 4) with no emotional investment because they never met Lura's loss in the slow burn the writer intended. The numbers worked; the story didn't land. Reviews say "I don't get the hype" because they didn't actually play the game the writer wrote. + +**Why it happens:** +Idle game economies reward optimization. The moment a fragment becomes a currency multiplier, players will grind for currency multipliers and treat fragments as throughput. *A Dark Room* avoided this by gating progression behind story events the player couldn't accelerate. Most narrative-idle games don't. + +**How to avoid:** +- **Story-gate progression, not just currency-gate it.** Season transitions should require *both* a Roothold threshold *and* the fragment that introduces the next Season's tonal shift. You cannot Season-transition until you have read the door-opening fragment. +- **Pace fragments by reading time, not by harvest count.** A fragment that took the writer 20 minutes to write should take ~20 minutes of dwell-time before the next harvestable fragment is available, not 20 seconds. This means harvest cooldowns and fragment-quality tiers, not raw RNG drops. +- **Acceleration purchases must accelerate *waiting*, not skip *content*.** This is already locked in PROJECT.md as a constraint — defend it ruthlessly when economic pressure arises. +- **Show, don't measure, optimization.** If the UI surfaces "fragments per hour" or "Roothold per click," players will optimize. If the UI surfaces "what Lura said yesterday," they'll inhabit. + +**Warning signs:** +- Speedrun routing emerges in playtester forums in Season 1 alone +- Players ask "what's the most efficient build?" — that's a sign the meaning didn't carry +- Session retention metrics spike on Season transitions (suggesting players are racing to the next tier) +- Discord chat is about numbers, not Lura + +**Phase to address:** +**Phase 2 (economy + first Season).** The first Season's economy *is* the contract for all subsequent Seasons. Get the gating philosophy right here. If Phase 2 ships with pure currency-gating, Phases 3-9 will inherit that flaw and retrofit will be expensive. + +--- + +### Pitfall 3: Save Format Becomes Untenable Mid-Project + +**What goes wrong:** +Six months in, you've shipped Seasons 1-2 to a closed alpha. Players have weeks of progression. Season 3 introduces canopy trees, which require a whole new save shape (per-tree memory state, place-memory unlock flags, vignette completion tracking). You realize your save format was a flat JSON blob with no version field. You can't write a migration because you don't know what version any given save is. You either (a) wipe everyone's save and lose your alpha cohort, (b) ship a save-recovery interview where players manually re-enter where they were, or (c) freeze save format and never add new mechanics, killing the 7-Season arc. + +**Why it happens:** +A 7-Season game with mechanic-heavy Seasons (especially 4-7 introducing storms, ecosystems, looms, returns) will require save-shape changes the original architect didn't anticipate. Idle games make this worse than non-idle games because **the save *is* the game** — there is no level-restart, the player's Roothold and fragment collection IS the value of having played. Losing it is unrecoverable. + +**How to avoid:** +- **Versioned saves from day one.** Every save serializes `{version: N, data: {...}}`. Never a flat blob. +- **Migration registry.** A function `migrateSave(save) -> save` that walks v1→v2→v3, never skipping versions. Forward-only. Tested. +- **Save schema lives in code as a typed structure** (TypeScript types or Zod schema), and adding a field requires bumping the version and writing a migration. Make this a CI check if possible. +- **Ship a "snapshot before migration" backup** every time the game loads a save and detects a version bump. Keep the last 3 snapshots. Recovery story exists. +- **Persist multiple copies.** localStorage + IndexedDB + (eventually) cloud. Different storage layers fail in different ways. Belt-and-suspenders is cheap and a 7-Season idle game **cannot afford** save loss. +- **Call `navigator.storage.persist()` explicitly.** Without it, browsers will silently evict your save under storage pressure (Chrome's eviction is documented). Request persistence on first save. +- **Test private-browsing modes.** Firefox Private and Brave Shields silently fail IndexedDB writes, so detect and warn the player rather than letting them lose progress on tab close. + +**Warning signs:** +- A new feature requires changing how an existing field is shaped, and there is no obvious place to put a migration +- A bug report says "I had X, now I have Y, no idea what happened" +- The save file is a JSON blob with no version field +- You're tempted to write `if (save.canopy === undefined) save.canopy = {}` inline at the read site (this *is* a migration; it just isn't named one) + +**Phase to address:** +**Phase 1 (foundations).** The save format and migration framework must be first-week infrastructure. Retrofitting versioning after Season 2 has shipped is materially more expensive than building it in. Treat save persistence as load-bearing system, not a checkbox. + +--- + +### Pitfall 4: System Clock Cheating Trivializes the Idle Loop + +**What goes wrong:** +A player advances their device clock by 30 days. The offline-progression delta-time math computes 30 days of fragment harvests and dumps them into the inventory. The player has just consumed a month of authored content in 90 seconds. The story's pacing collapsed. Worse, they did this not maliciously but because they were curious what was next. Now they have spoilers and no investment. + +**Why it happens:** +Standard idle-game offline progression is a simple `now() - lastSaveTime` calculation in client code. It is trivially exploitable. In most idle games this matters mainly for monetization (preventing free progression). In a narrative idle game it matters for **the story experience itself** — fast-forwarding ruins the work. + +**How to avoid:** +- **Cap offline progression.** A hard ceiling of (e.g.) 24 hours per offline period prevents both clock cheating and the "I came back after a vacation and harvested everything at once, breaking pacing" problem. This is the single highest-leverage mitigation. +- **Use monotonic time for sanity checks.** Persist the last-seen timestamp on every save; if `now() < lastSeen`, the clock went backwards, so refuse to advance progression and flag the save. +- **Monotonic in-game tick counter** (independent of wall clock) gates story beats. Story unlocks gate on tick count, not real-world time. This way, even successful clock cheats can't unlock the next fragment without the engine actually ticking. +- **Lean in to the cheat** — make it visible and self-defeating. If a player wants to skip ahead, let them, but show them a fragment that says "you were not here for this; come back when you are." The game's tone supports this in a way that an action game's wouldn't. + +**Warning signs:** +- Reddit / Discord threads about "best way to advance the clock" +- Telemetry showing players going from Season 1 to Season 4 in <1 day of actual elapsed wall time +- Save files with timestamps that don't make physical sense + +**Phase to address:** +**Phase 2 (economy + offline progression).** Build the cap and monotonic check at the same time you build offline progression. They are the same system. + +--- + +### Pitfall 5: AI-Generated Asset Style Drift Across Seasons + +**What goes wrong:** +You generate Season 1 plants with a Stable Diffusion LoRA tuned for "warm watercolor, slightly wrong real flora." Six months later you generate Season 4 storm-warped plants with a different prompt strategy and a model checkpoint that has rotated. The styles don't quite match. Then for Season 6 you've migrated to a newer model entirely. You have ~2,000 plant illustrations across 7 Seasons, and they look like they came from three different art teams. The watercolor consistency that the project's premise depends on has fractured. Worse, you can't regenerate the Season 1 assets to match Season 6's style because the original seeds and model checkpoints are gone. + +**Why it happens:** +AI image models drift in ways traditional art pipelines don't: +- Models update; same prompt produces different output 6 months later +- Sampler/scheduler defaults change between library versions +- Custom fine-tunes degrade if not re-trained against an authoritative reference set +- "Style drift" is the canonical failure mode of generative art at scale (per Scenario, Layer, and most published asset pipelines) + +A 7-Season project will span model generations. This is a near-certainty at current pace of model evolution. + +**How to avoid:** +- **Lock the model and pipeline.** Pin the exact model checkpoint, sampler, scheduler, library versions. Containerize it. Treat the model as a build dependency, version-controlled like any other. +- **Train on a curated reference set.** A custom-trained model (Scenario, LoRA, etc.) trained on a small, hand-picked set of "the look" produces more consistent output than prompting a foundation model every time. +- **Persist generation provenance.** Every asset stores `{model_id, checkpoint_hash, prompt, seed, sampler, params}` so any asset can be re-rolled identically months later. +- **Mandatory human curation gate.** Every asset that ships passes through a hand-refinement step where a human paints over, color-corrects, or rejects. AI proposes, humans dispose. The PROJECT.md already commits to this — defend it when schedule pressure arrives. +- **Visual regression testing across the full asset library.** When you migrate the model, render side-by-side comparisons and reject the migration if Season 1 assets look different from each other. +- **Establish a small, locked "north star" reference set** (10-20 paintings) hand-selected by the art lead. Every model evaluation compares against this set. Drift is detected by reference, not by feel. + +**Warning signs:** +- "I'll just use the latest version, it's better" (you cannot use the latest version mid-project; it will not match) +- Asset folders without provenance metadata +- Style decisions made on individual assets without comparing to the reference set +- Model API deprecation notices for the model you're depending on (this *will* happen) + +**Phase to address:** +**Phase 1 (foundations).** The asset pipeline itself is a Phase 1 deliverable, with provenance, locked model, and human curation gate **before** any production-volume asset generation begins. This is the single biggest schedule-and-quality risk for an AI-assisted multi-year project. + +--- + +### Pitfall 6: 7-Season Scope Eats the Solo Developer Alive + +**What goes wrong:** +The project was sold (to self) as "a 7-Season idle game like *A Dark Room*." Three years in, you've shipped Seasons 1-3, you're working 60-hour weeks, the energy you had for Season 1's writing is gone, Seasons 4-7 are in increasingly thin design docs, and you're about to either (a) ship a half-finished thing that fails the lineage comparison, (b) rewrite Seasons 1-3 because you've grown as a designer and they're now embarrassing, or (c) abandon the project. This is the documented failure mode of multi-year solo indie projects. PROJECT.md commits to *full 7 Seasons at v1*, which makes this risk acute. + +**Why it happens:** +- Solo devs lack the team feedback loops that catch scope drift +- The vision is sticky and revising it feels like betrayal +- Each Season *adds* mechanics (Storms, Looms, etc.), so production cost is not flat — it grows +- Three years is longer than most peoples' realistic creative-energy curve for a single project +- The narrative-idle audience expects polish (lineage of *Paperclips*) which means cuts are visible + +**How to avoid:** +- **Ship Season 1 publicly as soon as it stands alone.** Not "the demo of a 7-Season game" — Season 1 as a free, complete-feeling experience that earns the budget for Seasons 2-7. PROJECT.md commits to "Full 7 Seasons at v1" — read this carefully: it commits to v1 having 7 Seasons, but does NOT preclude shipping Season 1 first as a free prologue / extended demo. Use this loophole. The lineage games (*Dark Room*, *Paperclips*) earned their cult status before any sequel pressure existed. +- **Cap Season-introduced mechanics. Hard.** Each Season adds at most one new mechanic. If a Season's design adds three (e.g., the bible's Season 5 ecosystem could spawn ten), prune to one and reuse existing systems for the rest. +- **Identify "Roothold" features (work that compounds across Seasons) vs. "leaf" features (per-Season work).** Front-load Roothold features in Phase 1-2. Leaf features are scope you can cut. +- **Pre-write the entire authored content** (all fragments, all dialogue) in plain text before serious engineering ramps. If it can't be written, it can't be built. This makes scope visible early and turns "design" into "edit" in later phases. +- **Quarterly "kill list" review.** Every quarter, kill at least one feature from the requirements list. Not "defer" — kill. This is the only defense against scope creep that works for solo devs (per multiple post-mortems). +- **Real production schedule with buffer.** Multi-year solo projects routinely run 2-3x longer than plan. Either accept that 7 Seasons is 5-7 calendar years, or scope to fewer Seasons. PROJECT.md commits to multi-year — believe it and plan personal life around it (financial runway, relationships, mental health). + +**Warning signs:** +- Season N is "almost done" for >2 months +- Design doc for Season N+2 hasn't been touched in 6 months but is "still committed" +- You've stopped showing the game to anyone outside the team +- You can't articulate what's *not* in this game anymore (scope is now everything) +- Rewrite urge for shipped Seasons (this is a sign of personal growth eating velocity) +- Personal life metrics (sleep, relationships, exercise) trending wrong + +**Phase to address:** +**Every phase boundary.** This is a process pitfall, not a technical one. Build a quarterly review ritual where the kill list is mandatory. Build it into `/gsd-complete-milestone`. + +--- + +### Pitfall 7: BigNumber Overflow + Floating-Point Drift Late-Game + +**What goes wrong:** +Season 6 introduces ecosystem multipliers that compound on Roothold gain, which itself compounds across prestige cycles. Roothold values cross 1e308 (JavaScript Number maximum) and become `Infinity`. All comparisons against `Infinity` succeed, all upgrades become free, the economy collapses. Or — subtler — values around 1e15-1e16 lose integer precision (floating-point), so "you have 9007199254740993 fragments" displays as 9007199254740992 and incrementing it appears to do nothing. Players assume the game is broken. + +**Why it happens:** +JavaScript numbers are IEEE-754 doubles. Safe integer precision ends at 2^53 ≈ 9e15. Numeric values exceeding 1.79e308 become `Infinity`. Idle games routinely exceed both thresholds. The standard fix is `break_eternity.js` (or `break_infinity.js`, or `idle-bignum`), but switching from native numbers to a BigNumber library after the economy is built is painful — every arithmetic site must change, performance characteristics shift, and serialization changes. + +**How to avoid:** +- **Pick a number type day one.** If Roothold and fragments could plausibly exceed 1e15 (likely yes for 7 Seasons of idle compounding), use `break_eternity.js` for those values from the first economy commit. Native JS numbers for things that physically cannot grow large (counts of plant types, etc.). +- **Wrap in a typed abstraction.** Define a `BigQty` type and route all economic math through it. If you later have to switch BigNumber libraries (some go unmaintained), the change site is one file. +- **Display formatting from the start.** Show "1.23 e10" or "12 billion" — never raw scientific notation that confuses players. This is a UX issue, not just a math issue. +- **Test with absurd values.** Unit tests that simulate 30 prestige cycles of compounding Roothold should pass. If they overflow, you have a balance bug, not just a number-type bug. +- **Cap exponents at design level** (re: Pitfall 1). If Roothold has a narrative ceiling, BigNumber may not be needed at all. Decide first. + +**Warning signs:** +- Numbers in the UI show as `Infinity` or `NaN` +- A counter "stops incrementing" around 9e15 +- Save files contain `null` for values you expected to be numbers +- Comparisons in the economy code start behaving non-deterministically late-game + +**Phase to address:** +**Phase 2 (economy).** The first economy commit defines the number type. Choose `break_eternity.js` (or a wrapped equivalent) by default — easier to remove than to add. + +--- + +### Pitfall 8: localStorage Eviction and Browser-Update Wipes + +**What goes wrong:** +A player spent 80 hours over six months playing The Last Garden. Their Chrome updates overnight, or they hit a "clear browsing data" prompt by accident, or Safari's periodic eviction (a documented WebKit behavior, see WebKit bug 266559) wipes site storage, or they ran out of disk space and Chrome's storage-pressure eviction quietly cleared the site's IndexedDB. They open the game and they're back at Day 1 of Season 1. They refund / leave a one-star review / tell their followers. The story they emotionally invested in is gone — and unlike other genres, idle games are *just* the persistence; without it they have nothing to return to. + +**Why it happens:** +Browser storage is **best-effort** by default. localStorage and IndexedDB are evictable under storage pressure unless explicitly persisted. Safari has a documented periodic-erase behavior. Private browsing fails silently. Browser updates and CDN changes have cleared storage in past years (see itch.io thread). Most web games treat this as acceptable; for a 7-Season narrative idle it is catastrophic. + +**How to avoid:** +- **Call `navigator.storage.persist()` explicitly** on first save. Bumps the storage from best-effort to persistent on supporting browsers; reduces eviction risk. +- **Multi-layer persistence.** Write to *both* localStorage *and* IndexedDB on every save. They fail in different ways; surviving one failure mode is cheap. +- **Cloud backup, even minimal.** A "back up to email link" or "export save as .json" feature gives the player agency. A free cloud sync (one row per user, ~1KB) is cheap to host and saves the relationship when local storage fails. +- **Telemetry / save-loaded count vs. expected.** If a returning player loads a fresh save where one shouldn't exist, log it and surface "we noticed your save couldn't be found — do you have a backup?" in-game. Don't silently wipe. +- **Detect and warn on private-browsing.** "Your browser is in Private mode; saves will not survive this session" is a kindness. +- **Periodic "export reminder."** After Season transitions, prompt the player to download a save backup. Frame as "let's preserve this." + +**Warning signs:** +- No `navigator.storage.persist()` call in the save layer +- Single-storage strategy (localStorage only or IndexedDB only) +- No save-export feature +- "Save not found" being treated as "new player" without confirmation + +**Phase to address:** +**Phase 1 (foundations).** Save resilience is part of the save framework. Same phase as Pitfall 3. + +--- + +### Pitfall 9: FOMO/Nag Mechanics Violate Cozy Tone + +**What goes wrong:** +Mid-development, someone (a publisher, an investor, a friend who plays Genshin Impact) says "you should add a daily login bonus." Or "limited-time Memory Storms only this week." Or push notifications: "Lura is waiting for you." Or a streak counter that breaks if you skip a day. Each one looks like a reasonable engagement lever in a vacuum. Together they make the game feel like a phone-game obligation rather than a contemplative space. The game's premise ("the act of remembering, slowly") is denied by its own UX. + +**Why it happens:** +Live-ops design patterns are deeply embedded in mobile/idle game culture. Most idle game designers have internalized them as defaults. Cozy-game audiences specifically reject them (per genre criticism). The Last Garden lives at the intersection — its mechanics are idle, its tone is cozy. Without a clear "this is anti-pattern for us" doctrine, FOMO patterns *will* sneak in. + +**How to avoid:** +- **Anti-pattern doctrine, written down.** A list of mechanics this game does NOT use, with reasons: + - No daily login bonuses (presence is not a debt the game collects) + - No streaks (skipping a day is allowed, even encouraged) + - No limited-time content that disappears (the game's premise is *what persists*) + - No push notifications about progression (the cello plays whether you listen or not) + - No loss-aversion framing in copy ("you'll lose your X if you don't Y") + - No timers visible in the core UI (the cello, the seasons, those are timers — quiet ones) +- **Notification UX:** at most one opt-in notification class — Season transitions. Not progression. Not idle reward catch-ups. +- **Copy review pass.** Every player-facing string is reviewed for FOMO framing. "Don't miss out" and "limited time" are bannable phrases. +- **Engagement-without-anxiety as a design principle.** Optimize for *return on player's own schedule*, not *return at the schedule the analytics dashboard wants*. +- **Monetization that doesn't FOMO.** PROJECT.md already commits to cosmetic-only + Season acceleration + Keeper's Journal. Defend this. Most pressure to add FOMO comes from monetization anxiety. + +**Warning signs:** +- The team is reading mobile-idle live-ops post-mortems for "engagement" ideas +- "Daily login bonus" or "streak" appears in a meeting and isn't immediately rejected +- Push notification permissions are requested on first session (huge red flag — player hasn't consented to anything yet) +- Copy uses "don't miss" / "limited" / "only X hours left" +- Retention metrics become the primary KPI (vs. "did the story land") + +**Phase to address:** +**Phase 0 (planning) and continuously.** This is mostly a culture-and-doctrine pitfall, not a code pitfall. The doctrine ships before any code does. + +--- + +### Pitfall 10: Authored Content Diverges from Code + +**What goes wrong:** +Season 3 introduces canopy trees with place-memory vignettes. The writer drafts the vignettes in Google Docs, the engineer transcribes them into TypeScript files, and over six months the two diverge. The writer fixes a typo in Docs that never makes it to code. The engineer changes a fragment ID and the writer doesn't know. Live-ops fixes go into code, never back into the source-of-truth Docs. By Season 5, you have ~3,000 fragments, no canonical source, and a typo report from a player that nobody can confidently fix because nobody knows which version of which fragment is real. + +**Why it happens:** +Solo / small teams default to "code is the source of truth" because that's what ships. Writers naturally work in Docs / Notion / Markdown because that's where writing happens. Without an explicit content pipeline, the two formats drift. This is *the* canonical failure mode for content-heavy games (especially RPGs and visual novels) and is well-documented as a localization-blocker. + +**How to avoid:** +- **Single source of truth, in the repository, in Markdown / YAML / JSON.** Writers work in the repo (or in tooling that round-trips to it). Code reads the SOT format at build time. Never copy strings from a Google Doc into a TypeScript file. +- **Authored content lives in `/content/` (or similar)**, organized by Season, fragment, character. Engineering content (UI strings, error messages) lives separately. +- **Externalize all player-visible strings.** PROJECT.md's "v1 doesn't ship localized" is fine; it does *not* mean v1 should hardcode strings. Externalization is a one-time investment that costs ~nothing if done day one and is exponentially expensive to retrofit (per established i18n research). +- **Use stable fragment IDs that never change.** `season3.canopy.lura_07.vignette` not `fragment_274`. Renames are forbidden. +- **Spell-check / proofread CI.** A linter runs over the content directory and flags typos. A weekly proofreader pass is part of the production calendar. +- **Diff the content separately.** Writers can review content PRs without reading code. + +**Warning signs:** +- A fragment exists in two different files +- Strings are inside `.ts` / `.tsx` source files (UI strings, fragment text, dialogue) +- The writer asks "is this version up-to-date?" — that question's existence means it isn't +- Live fixes touch code; the canonical source isn't updated +- Numeric fragment IDs (renumbering will eventually happen and break references) + +**Phase to address:** +**Phase 1 (foundations).** The content pipeline is foundation infrastructure alongside save persistence and asset pipeline. Get it right before Season 1 fragments are written. + +--- + +### Pitfall 11: Web Audio Context Blocked, Cello Never Plays + +**What goes wrong:** +A player opens the game. The watercolor garden fades in. The cello — the *signature* tonal anchor — should swell. It doesn't. The Web Audio context is in the `suspended` state because the page hasn't received a user gesture, and the `play()` promise rejected silently. The player's first impression is not the cello. It's silence. They reload thinking it's broken. The mood is broken before it began. + +**Why it happens:** +Browser autoplay policies (Chrome 66+, Safari, Firefox) suspend AudioContext until user gesture. This is well-known in web-audio circles but trips up every team's first ship. The Last Garden's tonal anchor is the cello — getting this wrong is more damaging than for an action game where SFX-on-click works without trouble. + +**How to avoid:** +- **Explicit "press to begin" gate.** First screen is a hand-painted "Tend the garden" or "Begin" button. The click satisfies the gesture requirement *and* sets the tone. +- **Resume the AudioContext on first gesture** explicitly via `context.resume()`. Don't assume the browser handles it. +- **Test on iOS Safari, Mobile Chrome, Mobile Safari low-power mode.** Each has slightly different behavior. iOS in low-power mode also throttles `requestAnimationFrame`. +- **Loading screen plays the cello as soon as gesture allows.** This is the player's first emotional contact with the game; it must work. +- **Detect AudioContext suspension at runtime** and surface a "tap to restore audio" affordance if the context gets re-suspended (it can happen on tab focus changes). + +**Warning signs:** +- The cello starts on page load in dev (works without gesture in localhost — false positive) +- Audio code that calls `play()` without checking the returned promise +- No explicit "begin" gate on first session +- iOS / mobile Safari skipped in QA + +**Phase to address:** +**Phase 2 or 3 (when audio integration begins).** The audio bootstrap is a 1-day fix when planned and a 1-week fire when discovered at launch. Plan it. + +--- + +### Pitfall 12: Background Tab Throttling Breaks Offline Math Boundary + +**What goes wrong:** +The garden is "online progressing" because the player's tab is open in the background. But Chrome 88+ throttles background-tab JS timers heavily (1-minute resolution on the main thread, more aggressive after a minute of background time). The game's progression math, running on a `setInterval`, ticks at one minute when the player thinks it's ticking continuously. When they switch back, the game discovers the tab is far behind real time and has to reconcile — but the offline-progression code only runs on tab-load, not on tab-resume. The player has lost N minutes of growth they thought they were getting. Or worse: the offline code DOES run on resume, but double-counts the throttled foreground ticks the tab DID get, inflating progression. Either is a bug; both are common. + +**Why it happens:** +- Browser timers in background tabs are aggressively throttled (Chrome documented, Firefox documented) +- `requestAnimationFrame` pauses entirely in hidden tabs +- Web Workers continue running normally, which surprises many devs +- Most idle-game tutorials use `setInterval` on the main thread — fragile + +**How to avoid:** +- **Don't simulate progression with timers.** Use a "clock" approach: progression is `f(elapsed_real_time)`, not `f(number_of_ticks_received)`. On every render, compute current state from last-saved state plus real time elapsed. This is robust against throttling, throttling changes, and tab visibility. +- **Treat "tab visible" and "tab hidden" identically.** No special case. Just elapsed time. +- **Page Visibility API for save triggers.** Save on `visibilitychange` to `hidden`. Save on close. Save on Season transition. Save defensively. +- **For animation only, use rAF.** rAF pauses in hidden tabs, which is fine — animations don't matter when invisible. +- **Web Worker for heavy timed computation** if any (e.g., long pre-computation). Workers aren't throttled. + +**Warning signs:** +- The economy tick loop uses `setInterval(tick, 1000)` with a per-tick gain calculation +- Differences in player state when "I left the tab open in the background" vs. "I closed the tab and came back" — should be identical +- Offline-progression math runs on `load` only, not on `visibilitychange` +- Mobile testing shows different progression than desktop testing + +**Phase to address:** +**Phase 2 (economy + offline progression).** Same phase as offline progression itself; the architectural choice happens here. + +--- + +### Pitfall 13: DOM-Heavy Garden Becomes Unrenderable + +**What goes wrong:** +Season 5 is when ecosystem planting kicks in. A mature Season-5 garden has 60-300 plants on screen, each with hover affordances, bloom animations, growth-stage transitions, and ambient particle effects. If each plant is a DOM element with React state, you're animating hundreds of layout-affecting nodes per frame. The page hits 5 fps on a Chromebook. The watercolor mood becomes a stutter. iOS Safari runs out of layer memory and tabs crash. + +**Why it happens:** +DOM is convenient but slow at scale. An idle game with hundreds of moving sprites needs Canvas / WebGL. Mid-project migration from DOM to Canvas is expensive and architecturally invasive. + +**How to avoid:** +- **Pick a renderer that scales.** PixiJS (WebGL) and Phaser (Canvas/WebGL) handle hundreds-of-sprites scenes well. Plain DOM/CSS does not. Godot HTML5 export has documented mobile-perf issues (Godot issue 58836) — be cautious. +- **Profile early with worst-case scenarios.** Simulate Season-5 plant counts in Phase 1's prototype. If it doesn't perform, the renderer choice was wrong. +- **DOM for UI; Canvas/WebGL for the garden.** The split is fine and conventional. Use DOM where DOM is good (text, layout, accessibility) and Canvas where Canvas is good (sprites, particles). +- **Texture atlases, not per-asset image files.** Pack plant sprites into atlases. Each per-asset GL texture allocation has overhead. +- **Object pools for short-lived effects** (particles, drifting petals). Allocating and GC'ing thousands of objects per second causes hitches. +- **Memory ceiling test on iOS Safari.** Mobile Safari's per-tab memory limits are aggressive (~1GB on recent iPhones, lower on older). Run the largest expected scene on the oldest target device. + +**Warning signs:** +- Frame rate drops as the garden fills +- "Page Unresponsive" warnings on mobile +- DevTools shows hundreds of DOM mutations per frame +- Memory growing without bound during a session + +**Phase to address:** +**Phase 1 (foundations) — engine and renderer choice.** This is bound to engine selection. Pick an engine that handles Season 5+ render loads, validated by prototype. + +--- + +### Pitfall 14: Tonal Failure — "Cozy with Weight" Becomes "Depressing Slog" + +**What goes wrong:** +The story is *about grief*. The premise is loss. Done well, this is *Spiritfarer* and *Gris* — players say "I cried, and I'm grateful." Done poorly, this is "I quit at Season 3 because it was too sad and there was no relief." The line is invisible from the inside; you've been writing this for years and your sense of "is this too much" is broken. + +**Why it happens:** +- Grief content benefits from contrast — moments of warmth, humor, stillness — without which it becomes monotone-depressing +- Solo / small-team writing lacks the "this is too dark" feedback the team brings +- The "v1 must read as cozy at first glance and earn its emotional weight gradually" requirement (PROJECT.md constraint) is easy to violate by accident +- Cozy genre players are more sensitive to tonal mis-step than other audiences (per genre criticism) + +**How to avoid:** +- **Tonal pacing schedule.** For each Season, explicit beats: warm / quiet / heavy / lift. No Season is monotone-grief. Season 1 is *especially* not allowed to lead with grief (PROJECT.md constraint). +- **External readers.** Two or three trusted readers outside the team review every Season's fragments before integration. They flag "this is unrelenting" or "I don't have anywhere to breathe here." +- **Player check-ins.** Some games (e.g., *Spiritfarer*) include in-game "are you okay?" moments. The Last Garden's contemplative tone supports a similar gesture — a fragment that just says "rest, if you need to." +- **Content warnings opt-in.** Players who want them, get them. This is cheap to add and the cozy-game audience appreciates it. +- **Lura's voice as warmth anchor.** PROJECT.md identifies Lura as a named character. Write her as the contrast, not a co-griever. Without warmth, grief monotones. +- **Endings matter more than middles.** Season 7 (Return) must land warm. The whole point of "the garden persists" is that it's a *redemptive* persistence, not a pyrrhic one. + +**Warning signs:** +- Reader feedback like "I needed a break and there wasn't one" +- Every fragment in a Season has the same emotional register +- Lura's dialogue reads only as "also sad" +- Writer feedback to the writer (you): "I haven't laughed writing this in three months" +- Quit rates spike at a specific Season + +**Phase to address:** +**Phase 1 (vertical slice) and at every Season's content review.** Tonal calibration must be a milestone gate — no Season ships without external reader sign-off. + +--- + +## Technical Debt Patterns + +Shortcuts that seem reasonable but create long-term problems. + +| Shortcut | Immediate Benefit | Long-term Cost | When Acceptable | +|----------|-------------------|----------------|-----------------| +| Save format as flat JSON without version field | Ship faster, less ceremony | Save migrations impossible; alpha cohort wipe; users lose months of progress | **Never.** Day-one cost is ~10 lines. | +| Hardcoded fragment text in `.ts` files | "Just write code, fastest path" | Localization impossible, content/code drift, no proofread pipeline | **Never** for player-visible strings. OK for internal log messages. | +| Native JS numbers for economy values | Simpler, faster, no library | Overflow at 1e308, precision loss at 1e15, retrofitting BigNumber requires rewriting all economic math | Acceptable only if economy is provably capped well below 1e15 (rare for idle games) | +| `setInterval` ticker for progression | "It's just a game loop" | Tab-throttling breaks math; reconciliation bugs; mobile inconsistency | **Never** for progression math. Use elapsed-time-based clock instead. | +| Single-storage save (localStorage OR IndexedDB) | Simpler | Eviction = total loss; private mode failures; cross-device migration impossible | Acceptable for an arcade-style game; **never** for a 7-Season idle game. | +| Skipping `navigator.storage.persist()` call | One less line | Browsers will silently evict your save. Documented Chrome and Safari behavior. | Never | +| Generating AI assets without provenance metadata | Faster iteration | Cannot reproduce style 6 months later; no migration story when models update | Never for production assets. OK for throwaway concept exploration. | +| One mega-LoRA / model for all Seasons (no curation gate) | Lower production cost per asset | Style drift, IP/licensing fog, irreversible if model deprecates | Never for shipped assets. | +| Skipping `AudioContext.resume()` after gesture | Code reads simpler | First-impression mood broken on every browser supporting autoplay policy | Never | +| Daily login bonus / streak / FOMO mechanic | "Standard idle-game retention" | Violates cozy-tone constraint; alienates target audience; undermines thematic argument | **Never** in this game. | +| "We'll add localization later" / "scope cut for v1" | Ship Season 1 faster | i18n retrofit cost is exponential. Even if v1 doesn't localize, externalized strings cost ~nothing now and a fortune later. | OK to defer the *translation*. Not OK to defer *externalization*. | + +## Integration Gotchas + +| Integration | Common Mistake | Correct Approach | +|-------------|----------------|------------------| +| Web Audio API | Calling `play()` without user gesture; not handling `play()` rejected promise | Explicit "begin" gesture; check AudioContext state; call `resume()`; handle rejection gracefully | +| localStorage | Treating it as guaranteed persistent | Multi-layer (LS + IDB), call `navigator.storage.persist()`, ship export-save feature, telemetry on unexpected wipes | +| IndexedDB | Assuming writes succeed; not handling private-browsing failures | Wrap writes in try/catch; detect private mode; warn user; fallback paths | +| Page Visibility API | Not subscribing → lost saves on tab close | Save on `visibilitychange` to hidden; on `beforeunload`; on Season transition | +| Image generation API | Pinning latest model, ad-hoc prompts | Pin checkpoint, persist seed/prompt/params with each asset, hand-curation gate, locked reference set | +| Asset pipeline | Generating 1000s without curation | Human-in-the-loop on every shipped asset; reject-rate metrics | +| BigNumber library | Mixing native numbers and BigNumber arithmetic | All economic math through one wrapped abstraction; convert at boundaries only | +| Chrome timer throttling | Background `setInterval` for progression | Elapsed-time-based progression model; no main-thread tickers for economy | + +## Performance Traps + +| Trap | Symptoms | Prevention | When It Breaks | +|------|----------|------------|----------------| +| DOM nodes per garden plant | FPS drops as garden grows; layout thrashing | Canvas/WebGL renderer (PixiJS/Phaser), object pools | Around 50-100 simultaneously rendered plants | +| Per-asset GL texture (no atlas) | GPU memory grows; mobile crashes | Texture atlases packed at build time | Around 200-500 distinct sprites loaded; sooner on mobile | +| `setInterval`-driven progression | Inconsistent state vs. elapsed wall time; double-counting after tab resume | Elapsed-time clock; recompute state from saved-time + delta | Triggers any time tab is backgrounded > 1 minute | +| Audio buffer leaks | Audio glitching; memory growth across sessions | Decode and reuse audio buffers; don't re-decode per play | Long sessions; mobile Safari especially | +| Particle effects without pooling | GC stalls (visible hitches); frame drops | Object pools per particle type | Hundreds of particles/sec, common in Season-4 storms | +| Re-rendering whole garden each frame | CPU pegged; battery drain on mobile | Dirty flags, partial rerender, sprite caching | Every frame at 60fps with 50+ plants | +| Synchronous save serialization on big saves | UI freezes during save | Async / Worker-based save; incremental save (diff) | Save sizes >1 MB or after Season 4+ | +| Fragment text held all at once in memory | Slow load; mobile memory pressure | Lazy-load by Season; release prior Seasons' content when not visible | After Season 5, with 1000s of fragments authored | + +## Security / Integrity Mistakes + +Because the project is single-player, primarily client-side, security threats are about cheating and integrity, not multi-user data exposure. + +| Mistake | Risk | Prevention | +|---------|------|------------| +| Trusting client clock for progression | Player can fast-forward 30 days, ruining pacing AND auto-spoiling content | Cap offline progression; monotonic timestamp checks; refuse negative deltas | +| Save file exposed in plain JSON | Trivially editable Roothold values | Acceptable for solo single-player; this is not a competitive game; treat save tampering as the player's choice. **Do** sign saves if cloud sync exists, to prevent server-side abuse. | +| Server endpoints (if cloud sync added) without rate limiting | DoS, scraped account info | Rate limit per IP and per account; basic auth | +| AI-generated assets shipped without licensing audit | IP/licensing exposure (model trained on copyrighted reference; user-generated content; depending on jurisdiction) | Use models with clear commercial-use terms (e.g., self-trained LoRAs, commercially-licensed services); document provenance; legal review before launch | +| User-uploaded content (if Keeper's Journal supports it post-1.0) | XSS via journal annotation; CSAM/abuse vectors if shared | Sanitize all user-entered HTML; if sharing is added post-1.0, it requires moderation; not a v1 concern | +| Cosmetic monetization without receipt verification | Players claim purchases they didn't make; fraud | Standard payment-platform receipt verification (Stripe / Steam / etc.); replay attack prevention | + +## UX Pitfalls + +| Pitfall | User Impact | Better Approach | +|---------|-------------|-----------------| +| Numbers as the primary UI element | Players treat the game as a spreadsheet, not a story | Foreground the garden, the fragments, the cello. Numbers are present but quiet. | +| "X gained" toast spam | Breaks contemplative mood; mobile-game vibe | At most one "discovery" toast per fragment harvest. Most growth happens silently. | +| No "where am I?" affordance | Returning players forgot the story; bounce | Gentle "what happened last time" recap on session resume — *not* tutorial-y, in-fiction | +| Tutorial that explains the metaphor | Robs the player of the discovery (the metaphor IS the experience) | Tutorial teaches mechanics only; the meaning emerges. | +| First-session asks for permissions (notifications, audio) | Player hasn't consented; permissions feel demanding | Earn permissions. Cello plays after gesture. Notifications offered later, opt-in, framed in-fiction. | +| No save-export option | Player anxiety about losing 80 hours of play; rightful so given browser storage realities | Ship save export as core feature, not power-user feature. | +| Settings buried | Audio-sensitive players, accessibility-needing players bounce | Audio level, motion-reduction, content warnings — surfaced and accessible from any screen | +| Color/audio shifts without warning (Season transitions) | Photosensitivity / sound-sensitivity issues | Smooth transitions; "reduce motion" respected; volume normalization across Seasons | +| Default "click to harvest" feels rushed | Idle player who isn't there can't engage | Click is optional; idle progression is the floor. Click-rewards exist but never gate. | +| Mobile: tap targets too small, text too small | Cozy audience skews older; text-heavy game punishes small text | Min 44pt touch targets; readable type at default zoom; test on smaller phones | + +## "Looks Done But Isn't" Checklist + +- [ ] **Save persistence:** Often missing — verify saves survive browser update, private mode, storage pressure, app reload. Verify `navigator.storage.persist()` is called and returned `true`. Verify multi-layer write. +- [ ] **Save migration:** Often missing — verify a v1 save loads in a v3 build via migrations, not via "lost data, sorry." +- [ ] **Audio:** Often missing — verify cello starts on every tested browser (especially Safari, mobile Safari, mobile Safari low-power). Verify resume on tab focus. +- [ ] **Offline progression:** Often missing — verify cap; verify monotonic clock check; verify identical behavior between "tab open backgrounded" and "tab closed and reopened." +- [ ] **Asset pipeline:** Often missing — verify any asset can be regenerated identically months later from stored provenance. Verify human-curation gate isn't being skipped under deadline pressure. +- [ ] **Content pipeline:** Often missing — verify all player-visible strings are externalized (grep for string literals in JSX). Verify SOT is in repo, not in Docs. +- [ ] **BigNumber economy:** Often missing — verify simulated 30-prestige-cycle test passes without overflow or precision loss. +- [ ] **End state:** Often missing — verify Season 7 final fragment has actually been written and the "what after" experience exists. The credits, the rest state, the coda. +- [ ] **Tonal balance:** Often missing — verify each Season has been read by 2+ external readers and signed off on the warm-quiet-heavy-lift balance. +- [ ] **Anti-FOMO doctrine:** Often missing — verify no daily-login bonus, no streaks, no limited-time content, no nag notifications shipped under "engagement" pressure. +- [ ] **Performance:** Often missing — verify Season-5 plant counts render at 60fps on the oldest target device. Verify no DOM-per-plant pattern. +- [ ] **Localization-ready:** Often missing — verify externalized strings even if v1 isn't translated. Verify no text baked into image assets. +- [ ] **Accessibility:** Often missing — verify reduced-motion respected, text scales, color choices have non-color fallbacks (color blindness), audio captions/text equivalents. +- [ ] **Mobile testing:** Often missing — solo devs default to desktop. Verify iOS Safari, Android Chrome, on actual devices, not just emulator. +- [ ] **Save export:** Often missing — verify player can export and re-import their save. This is the relationship-saving feature when storage fails. + +## Recovery Strategies + +When pitfalls occur despite prevention, how to recover. + +| Pitfall | Recovery Cost | Recovery Steps | +|---------|---------------|----------------| +| Save format unversioned, alpha cohort live | HIGH | Sniff save shape; write best-effort migration with fallback to "interview" UI ("you were on Season X, with Y fragments — does this look right?"); ship versioning permanently going forward; apologize publicly. | +| Save loss (browser eviction) | HIGH if no backup; LOW if export-save was a feature | Recovery: ask player for exported save. Without export feature: a sympathetic email reply, an offer to start them at the right Season, and shipping export-save in the next patch. | +| BigNumber overflow shipped | MEDIUM | Migrate to `break_eternity.js`; values >1e308 in saves get clamped or recomputed; communicate honestly with affected players. | +| AI asset style drift mid-project | HIGH | Re-train on a curated reference; regenerate worst-offending assets with hand-paint pass; accept that some old assets may need rework. | +| Story finishes too soon (Pitfall 1) | HIGH | Season 7 coda + post-credits "rest" mode added in patch; reframe Roothold as finite; communicate the design intent clearly. | +| Players grinding past story (Pitfall 2) | MEDIUM | Add story-gates to subsequent Seasons; harvest cooldowns; existing players unaffected if applied to new progression only. | +| FOMO mechanic shipped, audience revolts | MEDIUM | Remove. Apologize. Don't try to "balance" it — cozy audiences read partial-removal as gaslighting. | +| Tonal failure (a Season is too dark) | MEDIUM | Edit the Season; ship a content patch; offer affected players a "rebalanced" replay path. Sensitive but manageable. | +| Tab throttling double-counting | MEDIUM | Refactor progression to elapsed-time-based; reconcile saves with detection of impossible deltas. | +| Audio doesn't start on Safari | LOW | Push a patch with explicit `resume()` and a "begin" gate; shipped within hours. | +| Hardcoded strings, localization required | HIGH (large surface) | Mass extraction with regex + manual review; this is weeks of solo-dev work and is the canonical case for "do it day one." | +| Solo scope death spiral (Pitfall 6) | HIGH | Recovery is a kill list and possibly a reduced-scope v1 (5 Seasons, with 6-7 as a free post-launch update). Far better outcome than abandonment. | + +## Pitfall-to-Phase Mapping + +| Pitfall | Prevention Phase | Verification | +|---------|------------------|--------------| +| 1: Story ends, idle loop doesn't | Phase 0/1 — design before code | End state exists in design doc; Season 7 written before Season 4; Roothold has finite ceiling | +| 2: Players grind past story | Phase 2 — first-Season economy | First Season ships with story-gating, not pure currency-gating; harvest cooldown by reading time | +| 3: Save format untenable | Phase 1 — foundations | Save has version field; migration framework + tests exist; multi-layer write | +| 4: System clock cheating | Phase 2 — offline progression | Cap on offline progression (24h); monotonic timestamp check; refuse negative deltas | +| 5: AI asset style drift | Phase 1 — asset pipeline | Pinned model; provenance metadata; human-curation gate; locked reference set | +| 6: 7-Season scope eats developer | Every milestone | Quarterly kill list; full content pre-written before engineering ramp; Season 1 ships standalone | +| 7: BigNumber overflow | Phase 2 — economy | `break_eternity.js` selected; wrapped abstraction; 30-prestige test passes | +| 8: Storage eviction | Phase 1 — foundations | `navigator.storage.persist()` called; multi-layer; export feature | +| 9: FOMO violates tone | Phase 0 — doctrine; continuous | Anti-pattern doctrine written; copy-review rule; monetization scope locked | +| 10: Content/code divergence | Phase 1 — content pipeline | All strings externalized; content SOT in repo; stable IDs; proofread CI | +| 11: Audio Context blocked | Phase 2/3 — when audio integrates | Begin gesture screen; explicit `resume()`; iOS Safari tested | +| 12: Tab throttling | Phase 2 — offline progression | Elapsed-time-based progression (no setInterval ticking); Page Visibility hooks | +| 13: DOM-heavy garden unrenderable | Phase 1 — engine choice | PixiJS/Phaser selected; Season-5 prototype runs at 60fps on target hardware | +| 14: Tonal failure | Phase 1 — vertical slice; every Season | External-reader gate; tonal pacing schedule; warmth contrast in every Season | + +## Sources + +- [The Math of Idle Games, Part III — Kongregate Blog](https://blog.kongregate.com/the-math-of-idle-games-part-iii/) — prestige-curve mechanics, exponential walls +- [The Idle Game Illusion: How Delta-Time Powers Progress](https://www.geekextreme.com/idle-games-offline-progression-math/) — offline-progression math and clock-cheat vulnerability +- [Math — the backbone of Idle Games (Medium)](https://medvescekmurovec.medium.com/math-the-backbone-of-idle-games-part-1-f46b54706cf1) — production curves, balance pitfalls +- [HTML5 local storage is lost at every update — itch.io](https://itch.io/t/2346400/html5-local-storage-is-lost-at-every-update) — real-world localStorage wipe on browser update +- [Why Your IndexedDB Data Keeps Disappearing — DEV.to](https://dev.to/denyherianto/why-your-indexeddb-data-keeps-disappearing-1m0a) — Chrome storage-pressure eviction +- [WebKit Bug 266559: Safari periodically erasing LocalStorage and IndexedDB](https://bugs.webkit.org/show_bug.cgi?id=266559) — documented Safari periodic eviction +- [Uncovering 8% IndexedDB Data Loss After Browser Crashes — DEV.to](https://dev.to/_eb7f2a654e97a60ae9f96e/uncovering-8-indexeddb-data-loss-after-browser-crashes-with-playwright-3j2m) — IDB loss rates under crash +- [break_eternity.js (Patashu)](https://github.com/Patashu/break_eternity.js) — BigNumber library for incremental games +- [Heavy throttling of chained JS timers in Chrome 88 — Chrome for Developers](https://developer.chrome.com/blog/timer-throttling-in-chrome-88) — background-tab timer throttling +- [Why do browsers throttle JavaScript timers? — Nolan Lawson](https://nolanlawson.com/2025/08/31/why-do-browsers-throttle-javascript-timers/) — cross-browser throttling behavior +- [Web Audio, Autoplay Policy and Games — Chrome for Developers](https://developer.chrome.com/blog/web-audio-autoplay) — Web Audio gesture requirement +- [MDN: Autoplay guide for media and Web Audio APIs](https://developer.mozilla.org/en-US/docs/Web/Media/Guides/Autoplay) — autoplay policy details +- [Pixi.js memory leak in Graphics — GitHub issues 5543, 8189, 11550](https://github.com/pixijs/pixijs/issues/8189) — PixiJS production failure modes +- [Troubleshooting Phaser Performance and Memory Issues — Mindful Chase](https://www.mindfulchase.com/explore/troubleshooting-tips/game-development-tools/troubleshooting-phaser-performance-and-memory-issues-in-large-scale-games.html) — Phaser long-session leaks +- [Godot HTML5 mobile performance — Godot issue 58836](https://github.com/godotengine/godot/issues/58836) — Godot web-export mobile-perf concern +- [Scenario — AI Asset Generation](https://www.scenario.com/) — style drift as canonical AI-pipeline failure mode +- [How to Create 3D Assets with AI in a Consistent Style — Sloyd](https://www.sloyd.ai/blog/how-to-style-consistent) — style consistency at scale +- [Layer — AI OS for Creative Teams](https://www.layer.ai/) — custom-model curation pipelines +- [Why Scope Is the Most Dangerous Enemy of Indie Games — All That's Epic](https://allthatsepic.com/blog/why-scope-is-the-most-dangerous-enemy-of-indie-games) — scope as primary cause of indie failure +- [Scope Creep: The Silent Killer of Solo Indie Game Development — Wayline](https://www.wayline.io/blog/scope-creep-solo-indie-game-development) — solo-dev scope failure pattern +- [What I Learned About Failing from My 5 Year Indie Game Dev Project — HN discussion](https://news.ycombinator.com/item?id=24028289) — multi-year solo project post-mortem +- [Solo Game Dev: Lessons Learned the Hard Way — Romain Mouillard](https://medium.com/@romain.mouillard.fr/solo-game-dev-lessons-learned-the-hard-way-0720138000bd) — concrete solo-dev lessons +- [How Video Games Abuse The Fear of Missing Out — Game Wisdom](https://game-wisdom.com/critical/fomo) — FOMO mechanics and audience trust erosion +- [If all is cozy, what isn't? Conceptual problems regarding cozy games — Into the Magic Circle](https://intothemagiccircle.org/article/view/18928) — cozy-genre tonal expectations +- [Exploring Themes of Grief and Loss Through Video Games — Game Developer](https://www.gamedeveloper.com/production/exploring-the-themes-of-grief-and-loss-through-video-games) — grief content design considerations +- [Game Localization: Complete Guide — better-i18n](https://better-i18n.com/en/blog/game-localization-complete-guide/) — localization retrofit costs +- [The technical side of localization in video games — Alpha CRC](https://games.alphacrc.com/the-technical-side-of-localization-in-video-games/) — hardcoded-string failure modes +- [How to prevent time cheating in idle games — GameMaker forum](https://forum.gamemaker.io/index.php?threads/how-to-prevent-players-from-time-cheating-an-idle-game.96202/) — clock-cheat prevention patterns +- [Save File Upgrades — Don't Starve Together API](https://vietnd69.github.io/dst-api-webdocs/docs/game-scripts/core-systems/data-management/saves/savefileupgrades) — save-version migration pattern reference + +--- +*Pitfalls research for: The Last Garden — browser-based narrative idle game* +*Researched: 2026-05-08* diff --git a/.planning/research/STACK.md b/.planning/research/STACK.md new file mode 100644 index 0000000..54cdfb6 --- /dev/null +++ b/.planning/research/STACK.md @@ -0,0 +1,241 @@ +# Stack Research + +**Domain:** Browser-based narrative idle game (cozy + watercolor 2D + 7-Season prestige + AI-assisted content pipeline) +**Researched:** 2026-05-08 +**Confidence:** HIGH (engine + numeric + audio + bundler) / MEDIUM (animation + content pipeline) / LOW (cloud save backend — depends on launch scope) + +--- + +## TL;DR — The Opinionated Stack + +**Engine:** Phaser 4 (v4.1.0+) with TypeScript, mounted inside a React shell that owns all narrative/menu UI. Vite for tooling. Howler.js for music crossfading on top of Phaser's audio. break_eternity.js for the prestige economy. Zustand for the game-state store that bridges Phaser scenes and React UI. Ink (via inkjs) for branching narrative content. lz-string + IndexedDB for saves. PocketBase as the eventual cloud-save backend. Spine (spine-phaser runtime) for the few characters that need rigged animation; everything else is hand-painted PNG sequences and PixiJS-style sprite work that Phaser handles natively. + +**Why this combination:** The dimensions of your game pull in opposite directions. The watercolor-and-cello atmosphere wants a renderer with strong asset fidelity. The 7-Season scope with thousands of memory fragments wants a framework that doesn't make you reinvent scene management, tweens, audio, and asset loading. The narrative-text density wants real DOM (React) for accessibility, font rendering, and i18n later. The AI-assisted content pipeline wants plain-text authoring formats (Ink, JSON, MD+frontmatter) the team can iterate on without engine round-trips. Phaser-4-as-engine + React-as-UI-shell hits all four; nothing else does. + +--- + +## Recommended Stack + +### Core Technologies + +| Technology | Version | Purpose | Why Recommended | +|------------|---------|---------|-----------------| +| **Phaser** | 4.1.0+ ("Salusa", April 2026) | 2D game framework: scenes, asset loader, camera, tweens, input, particle system, audio manager | Phaser 4 ships a rebuilt-from-scratch WebGL renderer (cited "100x faster" on internal tests vs Phaser 3), unified filter system, ESM modules, and official integrations for Vue/React/Next/Svelte/Solid. It is an actual *framework* (not just a renderer like PixiJS), which removes ~6 months of "build the engine before the game" work. Crucially: Phaser 4's official `create-phaser-game` CLI scaffolds a React + Vite + TypeScript template out of the box. (HIGH confidence) | +| **TypeScript** | 5.6+ | Static typing for game state, save schema, narrative content schema | A 7-Season game with a complex authored prestige economy and persistent-save migration is a TypeScript-mandatory project. Save schema versioning (mandatory for an idle game) requires types that compiler-check migrations. (HIGH) | +| **React** | 19.x | UI shell: title screen, journal, settings, narrative dialog overlays, fragment viewer, premium Keeper's Journal | Phaser's canvas can render text, but for **paragraphs of authored prose, the cozy-game audience expects browser-native typography, copy-paste, accessibility, screen-reader support, and i18n**. Render the world (garden, plants, weather, watercolor canvas) in Phaser; render the narrative chrome in React absolutely-positioned over (or around) the canvas. This is now the dominant pattern and Phaser 4 has a first-class React template. (HIGH) | +| **Vite** | 6.x (Vite 8 with Rolldown landing 2026) | Dev server, HMR, prod bundler | Vite cold-start (~1.2s) and HMR (~10–20ms) vs Webpack (cold ~7s, HMR 500ms–3s) is a solo-team productivity multiplier. Native TypeScript via esbuild, no loader config. Phaser 4's official template ships with Vite. Vite 8 will replace esbuild+Rollup with Rolldown (single Rust compiler, dev = prod), but Vite 6 is fine to start on today. (HIGH) | +| **Zustand** | 5.x | Game state store; bridge between Phaser scenes and React UI | Idle games have a single canonical state tree (resources, plants, fragments collected, season progress, prestige currencies). Zustand's single-store API + 5-star TS support + ~12ms single-update latency + tiny bundle (≈3KB) is the right shape. Redux is over-engineered for a single-player game; Jotai's atomic model fights you when ~80% of state mutates together each tick. (HIGH) | +| **break_eternity.js** | 2.1.3 (Dec 2025) | Big-number library for prestige economies (numbers up to 10^^1e308) | Drop-in successor to break_infinity.js / decimal.js. Same interface, comparable perf (within 2x), supports very small numbers (e.g. 1e-400 tickspeeds) which break_infinity does *not*. Includes built-in `index.d.ts` — TypeScript-native. The standard choice for serious modern idle games. (HIGH) | +| **inkjs** | latest (active fork: `inkle/inkjs` and `y-lohse/inkjs`) | Runtime for Ink narrative scripts (branching dialog, variables, knot/stitch flow) | Ink is the proven narrative scripting language behind 80 Days, Sorcery!, Heaven's Vault, A Highland Song. Inkjs is the official JavaScript port — zero deps, browser + Node, TypeScript imports under `/types`. Authors write `.ink` in the free Inky editor; you compile to JSON and stream into the game. **This is the single highest-leverage decision in your content pipeline** because it lets your writer iterate in a tool designed for them, not in source code. (HIGH) | +| **Howler.js** | 2.2.4 (Sept 2024, actively maintained, 25.3k stars) | Cross-browser audio with Web Audio API + HTML5 fallback; dedicated music + ambience layer | Phaser 4 has native Web Audio with `fadeIn`/`fadeOut`, but Howler is the industry standard for **layered, crossfading, looping music with reliable mobile-Safari behavior**. Use Phaser's audio for SFX (interleaved with game events, scene-bound) and Howler for the persistent music+ambience bed that crossfades across Seasons independent of scene transitions. Splitting these is the cleanest architecture for a 7-Season tonal-shift soundtrack. (HIGH) | + +### Supporting Libraries + +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| **lz-string** | 1.5.x | Compress save JSON before storage (50–70% reduction; UTF-16 safe for localStorage) | Always. Idle game saves grow with collected fragments; hitting the 5MB localStorage cap is a real concern by Season 5+. Use `compressToUTF16` for localStorage and `compressToBase64` for cloud-save payloads. (HIGH) | +| **idb** (or **idb-keyval**) | 8.x | Promise-based IndexedDB wrapper | Use as primary save target instead of localStorage. IndexedDB is async (non-blocking — important when saves get large), has ~50% of disk available, and survives browser cleanup pressure better. Keep localStorage as fallback for tiny "last-played-timestamp" check. (HIGH) | +| **spine-phaser** | 4.2+ (matches Spine editor 4.2 "physics revolution") | Skeletal animation runtime for characters that need rigged motion (Lura, the Nameless Man, possibly the Archivist) | Use **only** for the 3 named characters and any 2-3 hero plants that benefit from skeletal motion. Sprite sheets use 40–70% less GPU memory than skeletal animation, so for the 100+ background plants stick to PNG sequences or static art with shader sway. Esoteric upgraded spine-phaser to support Phaser 4 directly. (MEDIUM — depends on whether you commit to skeletal at all) | +| **@pixi/ui** | n/a (only if going PixiJS) | UI components inside the Pixi canvas | **NOT recommended for this project** — see "What NOT to Use." Listed only because it's the canvas-UI option if you reverse the engine choice. | +| **gray-matter** | 4.x | Parse YAML frontmatter from `.md` files | For the **fragment library** specifically: each memory fragment is a Markdown file with frontmatter (`id`, `season`, `unlock_condition`, `tags`, `length_seconds`, `voice_tone`). gray-matter + a build-time aggregation step turns a directory of fragments into a single typed JSON manifest the game loads. This is the pipeline shape that scales to thousands of authored fragments without a CMS. (MEDIUM — pattern is well-proven, exact tool is interchangeable) | +| **zod** | 3.23+ | Runtime schema validation for save data and content | Validate every save on load (catches corrupt saves before they crash the game) and validate every fragment manifest entry at build time (catches typos in fragment frontmatter before they ship). Critical for an idle game where saves persist for months/years across game updates. (HIGH) | +| **mitt** | 3.x | Tiny event emitter (~200 bytes) | Cross-cutting events that don't fit Zustand's reactive model: "season-changed", "memory-storm-triggered", "fragment-unlocked". Used to decouple Phaser scenes, React UI, and Howler music from each other. (MEDIUM) | +| **dayjs** | 1.11+ | Time math for offline progression | Idle games need solid timestamp-diff calculations including DST/TZ edge cases. Lighter than Luxon/date-fns; sufficient API for "how long was the player gone?" with no surprises. (MEDIUM) | +| **vite-plugin-checker** | 0.8+ | Run TypeScript + ESLint in a separate process during dev | Keeps Vite's ESM-on-demand speed while still surfacing type errors live. (HIGH) | + +### Development Tools + +| Tool | Purpose | Notes | +|------|---------|-------| +| **`npm create @phaserjs/game@latest`** | Scaffold Phaser 4 + React + Vite + TypeScript template | Use the React template, not the vanilla one. This is the official path and will save days of integration work. | +| **Inky** (free, by Inkle) | Narrative authoring environment for `.ink` files | The writer-facing tool. Ships with a live preview that simulates branches. Compile to JSON via the Ink CLI as a build step. | +| **Spine** (Esoteric, $69 Essential / $339 Pro one-time) | 2D skeletal animation editor | Only purchase if you commit to rigged characters. Free trial covers prototyping. The Pro license unlocks meshes/skinning, which you likely want for watercolor textures that need to deform. | +| **Aseprite** ($20) or **Photoshop** | Watercolor texture authoring + frame-sequence cleanup of AI-assisted output | Aseprite is the indie 2D standard; Photoshop is better if your watercolor pipeline starts in real watercolor + scan or in AI image generation. Given the AI-assisted-then-hand-refined constraint, Photoshop is the more honest fit. | +| **TexturePacker** ($40) or **free-tex-packer** | Pack PNG sequences into atlases Phaser/Spine can stream | Atlas packing is mandatory for a watercolor game — individual high-res PNGs blow draw-call budget fast. | +| **Audacity** (free) or **Reaper** ($60 indie) | Audio editing for cello recordings + ambience | Reaper's noncommercial license covers the entire dev cycle of a solo project. | +| **Vitest** | Test runner for game-state logic, save migrations, prestige math | The non-rendering parts of an idle game (economy, save migrations, fragment unlock conditions) **must** be unit-tested. Vitest is the Vite-native choice. | + +--- + +## Installation + +```bash +# Scaffold (run once) +npm create @phaserjs/game@latest my-garden +# → choose: React + Vite + TypeScript + +cd my-garden + +# Core game runtime +npm install phaser@^4.1.0 +npm install break_eternity.js@^2.1.3 +npm install inkjs@latest +npm install howler@^2.2.4 @types/howler +npm install zustand@^5.0.0 +npm install zod@^3.23.0 +npm install mitt@^3.0.0 +npm install dayjs@^1.11.0 + +# Save persistence +npm install idb@^8.0.0 +npm install lz-string@^1.5.0 @types/lz-string + +# Content pipeline +npm install gray-matter@^4.0.0 + +# Optional: skeletal animation +npm install @esotericsoftware/spine-phaser@^4.2.0 + +# Dev dependencies +npm install -D typescript@^5.6.0 +npm install -D vite@^6.0.0 +npm install -D vite-plugin-checker +npm install -D vitest @vitest/ui +npm install -D @types/node +``` + +--- + +## Alternatives Considered + +| Recommended | Alternative | When to Use Alternative | +|-------------|-------------|-------------------------| +| **Phaser 4** | **PixiJS 8** | If you reach Season 5 and discover you need 5,000+ animated plant sprites on screen at 60fps with custom shaders. PixiJS is ~3x smaller (450KB vs 1.2MB), faster at pure rendering, and has a richer filter pipeline for watercolor-style post-processing. The cost: you'd be writing scene management, asset loading, audio routing, and tween systems yourself. Verdict: not worth it for The Last Garden's actual rendering load. | +| **Phaser 4** | **Excalibur.js** | If the team had a strong C# / Java background and wanted the most "game-engine-y" TypeScript-first framework. Excalibur is genuinely good but **still pre-1.0** (0.x), has a much smaller asset/plugin ecosystem, and lacks the official integration templates. Risk-adjusted: the 7-Season scope is too long to bet on a pre-1.0 engine. | +| **Phaser 4** | **Godot 4 HTML5 export** | If the team strongly prefers a node-based scene editor and is willing to accept ~20–40MB minimum download size and a ~10–15% performance gap vs native. GDScript is fine for game logic but is an additional language for AI tooling to support and a recruiting constraint. The Last Garden's content pipeline is text-heavy, not scene-tree-heavy, so the editor advantage is muted. | +| **break_eternity.js** | **break_infinity.js** | If you can prove the prestige economy will never need numbers above 1e1e308 *and* you don't need very small numbers (<1e-308). break_eternity is a drop-in upgrade with comparable perf, so there's almost no reason to choose the older library now. | +| **Ink (inkjs)** | **Yarn Spinner** | If the writer prefers Yarn Spinner's syntax. Yarn's strength is Unity; its JS/web story is weaker than Ink's, and Godot/Unreal support was still alpha as of early 2026. Ink is more battle-tested in browser-native deployment. | +| **Ink (inkjs)** | **Twine + custom JSON export** | If the narrative is primarily hyperlink-style hypertext rather than variable-driven branching dialog. The Last Garden's fragments are short prose pieces with conditional unlocks — closer to Ink's strengths than Twine's. | +| **React UI shell** | **Phaser-native UI (`@phaserjs/dom-ui` or in-canvas Text)** | If you decide accessibility, screen-reader support, and copy-paste of fragments are not priorities. They almost certainly are for a narrative-cozy audience. | +| **Zustand** | **Jotai** | If late in development you discover you have many independent atomic state slices that don't update together. Idle games rarely fit that shape — game state ticks as one unit. | +| **PocketBase** | **Supabase + Cloudflare Workers** | If you expect >10K concurrent players or want managed Postgres. PocketBase is single-server SQLite (great until ~10K MAU on a $5 VPS); Supabase scales further but adds operational complexity. Defer this decision until you have real player data — local-only saves are fine for v1 launch. | +| **Howler.js** | **Tone.js** | If the music system needs procedural composition or DSP effects beyond crossfading and looping. The Last Garden's audio is *recorded cello + recorded ambience*, not procedurally generated, so Tone.js is overkill. | +| **lz-string** | **pako (DEFLATE/gzip)** | If saves grow past ~500KB compressed. Pako compresses 60–75% (vs lz-string's 50–70%) but is larger and slower for small strings. For a fragment-collecting game with text-heavy saves, this could matter eventually — easy swap. | +| **Spine** | **DragonBones** (free) | If budget is the constraint. DragonBones is genuinely capable and free, but the runtime situation in 2026 is messier — Spine has official Phaser 4 + PixiJS runtimes maintained by the engine authors themselves. For a multi-year project, the maintenance story matters more than the license fee. | +| **Spine** | **Frame-by-frame PNG sequences only** | **This is the most likely actual answer for v1.** Watercolor + AI-assisted production lends itself to *image-sequence-of-frames* much better than to skeletal rigging — you can't easily "rig" a watercolor wash. Use Spine only if specific characters demand it; default to image sequences. | +| **Vite** | **Webpack** | If the team has heavy investment in a Webpack-based build pipeline. None applies here — greenfield project. | + +--- + +## What NOT to Use + +| Avoid | Why | Use Instead | +|-------|-----|-------------| +| **Unity WebGL** | (1) 25–60MB+ download is fatal for the *A Dark Room* / *Paperclips* lineage, where one of the genre's defining traits is "starts in 3 seconds in any browser tab." (2) Unity WebGL has memory and startup-time issues that compound on lower-end devices and mobile browsers. (3) Cosmic-overkill for a 2D narrative idle game. (4) Locks you out of the cozy-narrative-idle audience that plays at work. | Phaser 4 (browser-native, ~1.2MB framework, instant load) | +| **Unreal Engine HTML5** | Doesn't really exist as a supported target anymore; what does exist produces multi-hundred-megabyte builds. Same audience-destruction argument as Unity WebGL but worse. | Phaser 4 | +| **Phaser 3** | Phaser 4 (April 2026) is the active line. Phaser 3 still works, but the official tooling, courses, migration guides, and Spine runtimes have all moved to Phaser 4. Starting greenfield on Phaser 3 in 2026 is choosing the deprecated branch. | Phaser 4 v4.1.0+ | +| **PixiJS as the *only* framework** | PixiJS is a *renderer*, not a *framework*. You'd build your own scene system, asset loader, input router, audio manager, tween engine, and physics layer. That's 6 months of yak-shaving before Season 1's loop is playable. | Phaser 4 (uses PixiJS-style WebGL rendering under its renderer abstraction anyway) | +| **react-pixi-fiber / @pixi/react** | Reasonable for single-screen interactive graphics, but the React-reconciler layer over the Pixi scene graph adds complexity and per-tick overhead that an idle game's tick loop will hit constantly. The Phaser+React-shell pattern keeps React out of the hot path. | React for UI shell; Phaser owns the canvas | +| **decimal.js** (alone) | Arbitrary precision, not arbitrary magnitude — slower than break_infinity/eternity for the same idle-game use case, and runs out of room above ~1e9e15 anyway. | break_eternity.js | +| **Plain `Number` for prestige currencies** | JavaScript Number maxes at 1.79e308 then becomes Infinity. A 7-Season prestige game with exponential growth *will* exceed this within a few prestige cycles. Discovering this in Season 4 means refactoring every formula. | break_eternity.js from day one | +| **localStorage as primary save** | Synchronous (blocks main thread on large saves), 5MB hard cap, and gets cleared by browsers under storage pressure. Fine as a fallback or for a "last save timestamp" key. | IndexedDB (via `idb`) for primary saves; localStorage fallback for the bookkeeping key | +| **JSON.stringify with no compression** | Save bloat is real for fragment-heavy games. A 2MB uncompressed save compresses to ~600KB with lz-string and stays well clear of any cap. | lz-string (`compressToUTF16` for IDB/localStorage, `compressToBase64` for cloud) | +| **Yarn Spinner for narrative** | Best support is Unity; web/JS support exists but lags Ink. Godot/Unreal integrations were still alpha as of early 2026. | Ink + inkjs | +| **Twine** for the fragment system | Twine is *story-shaped* (hypertext nodes); your fragments are *content-shaped* (typed records with metadata). Mismatched data model. | Markdown-with-frontmatter files compiled to JSON manifests (gray-matter) | +| **Vanilla JavaScript** (the *Paperclips* approach) | *Paperclips* was 1 person, 1 screen, no animation, ~2,000 lines. The Last Garden is 7 Seasons of authored content, watercolor visuals, named characters, prestige currencies, save migrations across multi-year update cycles. The Paperclips approach was right for Paperclips and wrong for this. | TypeScript + Phaser 4 + Vite | +| **Redux Toolkit** | Boilerplate-heavy for a single-player game with no time-travel debugging requirements. ~50% larger memory footprint than Zustand for the same workload. | Zustand | +| **GDScript-only stack (Godot)** | Adds a second language to a solo/small team that needs AI tooling support across the whole codebase. Web export bundles are 20–40MB+ minimum. | TypeScript everywhere via Phaser 4 | +| **Tone.js** | Procedural-audio framework. Your audio is recorded, not synthesized. | Howler.js | +| **Custom Canvas + TypeScript from scratch** | You'll build Phaser. Worse, slower, less tested. The "I'll just write Canvas code" instinct dies in Season 2 when you need particle effects for the Memory Storms. | Phaser 4 | + +--- + +## Stack Patterns by Variant + +**If solo developer (likely):** +- Stick to the recommended stack exactly. +- Don't add Spine in v1 — frame-sequence PNGs only. Add Spine in a v1.x update if a specific character benefits. +- Defer cloud saves entirely. Local-only is fine for launch; the *A Dark Room* / *Paperclips* lineage shipped without cloud saves. + +**If 2–3 person team with a dedicated artist:** +- Add Spine from the start for the 3 named characters. +- Add a minimal PocketBase deploy (single $5 VPS, ~15MB binary) for opt-in cloud saves at launch. PocketBase ships with auth + REST + admin UI, so it's roughly a weekend of integration work. + +**If you decide to skip React for the UI shell:** +- Don't. The narrative-cozy audience will use screen readers, will copy-paste fragments to share, will run at 200% browser zoom. Canvas-native text fights all of these. +- If you absolutely must, use Phaser 4's built-in Text + a custom modal system, accept that journals/fragment views will feel less native, and budget for accessibility complaints. + +**If the watercolor look needs more shader work than Phaser's filter system supports:** +- Phaser 4's unified filter system covers most needs (post-process bloom, color grading by Season, paper-texture overlays). +- If you hit a wall, you can run a PixiJS render layer alongside Phaser for one specific effect — Phaser 4's renderer is PixiJS-compatible at the WebGL level. + +**If audience testing reveals players want voiced dialogue post-launch:** +- Howler.js handles this trivially (it's just more audio sprites). The architecture survives the addition. + +--- + +## Version Compatibility + +| Package A | Compatible With | Notes | +|-----------|-----------------|-------| +| `phaser@^4.1.0` | `vite@^6` or `^7` | Phaser 4's official template uses Vite. ESM-first; Phaser 4 fixed several ESM build issues in v4.1.0. | +| `phaser@^4.1.0` | `@esotericsoftware/spine-phaser@^4.2.x` | Esoteric upgraded spine-phaser specifically for Phaser 4 in collaboration with Phaser's author. **Do not** use spine-phaser v3 with Phaser 4. | +| `react@19` | `phaser@^4.1.0` | React 19 owns the DOM; Phaser owns the canvas. They coexist via a `` mounted by `useEffect`. The official template handles this. | +| `inkjs@latest` | TypeScript via `inkjs/types/Story` etc. | The TypeScript classes are under the `/types` submodule, not the root export. | +| `break_eternity.js@^2.1.3` | TypeScript native | Ships `index.d.ts`. No `@types/...` package needed. | +| `howler@^2.2.4` | All evergreen browsers + iOS Safari | Howler abstracts the iOS-Safari "must touch screen first" gotcha that bites raw Web Audio code. | +| `idb@^8` + `lz-string@^1.5` | Compose freely | Compress the JSON string with lz-string, then store the compressed string as a single value in IndexedDB. Don't try to store the parsed object directly — keep the indirection so save migrations can run on the string. | +| `zustand@^5` | React 18+ | Zustand v5 dropped some React 17 compat. Fine here since we're on React 19. | + +--- + +## Content Pipeline (Specific to Memory Fragments) + +This is the load-bearing piece of your production model and deserves explicit treatment. + +**The author writes:** +- `.ink` files for branching dialog scenes (the three named characters' arcs, place-memory vignettes, the final binary choice). +- `.md` files with YAML frontmatter for individual memory fragments. Example: + ```markdown + --- + id: f_0247_theLatch + season: 3 + unlock_when: { plant: "winter-rose", harvested_at_least: 3 } + tone: melancholy + characters: [Lura] + length_seconds: 8 + --- + The latch on the gate she always meant to fix. + Not broken — never broken. Just stiff in the cold, + the way her father's hands got, the year before. + ``` + +**Build step does:** +1. Walk `content/fragments/**/*.md`, parse with gray-matter, validate each entry against a Zod schema, fail the build on schema violations. +2. Aggregate into `dist/fragments.json` (compressed with lz-string before bundling). +3. Compile every `content/dialog/*.ink` to JSON via the Ink CLI. +4. Generate a TypeScript `.d.ts` file enumerating fragment IDs so unlock conditions are type-checked at compile time. + +**The game runtime:** +- Lazy-loads fragments by Season (ship Season 1 fragments in the initial bundle, fetch Seasons 2–7 on Season transition). +- Streams Ink stories via `inkjs`, with `Story.variablesState` tied to the Zustand store so narrative branching reacts to game state. +- Uses Howler to crossfade the Season's musical bed when an Ink story triggers a `# music: storm_theme` tag. + +**Why this shape:** It separates *authoring* (writer-friendly tools) from *engineering* (typed, validated, lazily loaded). It scales to thousands of fragments without a CMS. It survives AI-assisted production because Markdown+frontmatter is exactly what LLMs generate well. And it lets the writer iterate on Season 4 in Inky without rebuilding the game. + +(MEDIUM confidence on the exact tool choices; HIGH confidence that *some* version of this shape is correct. Specific tools are easy swaps; the architecture is the recommendation.) + +--- + +## Sources + +- [Phaser 4 release notes (v4.1.0 "Salusa", Apr 2026)](https://phaser.io/news) — verified Phaser 4 is the active stable line; HIGH +- [Phaser CLI / `@phaserjs/create-game`](https://github.com/phaserjs/phaser) — verified React+Vite+TS template; HIGH +- [PixiJS v8.18.1 release (Apr 2026)](https://github.com/pixijs/pixijs/releases) — verified version; HIGH +- [break_eternity.js v2.1.3 (Dec 2025)](https://github.com/Patashu/break_eternity.js) — verified TS-native, drop-in for break_infinity.js/decimal.js; HIGH +- [Howler.js v2.2.4 (Sept 2024)](https://github.com/goldfire/howler.js/releases) — verified active maintenance; HIGH +- [inkjs (inkle/y-lohse forks)](https://github.com/inkle/inkjs) — verified TS support and browser compat; HIGH +- [Spine-Phaser runtime upgraded for Phaser 4](https://en.esotericsoftware.com/spine-pixi) — verified runtime compatibility; HIGH +- [Vite vs Webpack 2026 benchmarks](https://dev.to/pockit_tools/vite-vs-webpack-in-2026-a-complete-migration-guide-and-deep-performance-analysis-5ej5) — HMR/cold-start metrics; MEDIUM +- [State Management 2026: Zustand vs Jotai vs Redux Toolkit vs Signals](https://dev.to/jsgurujobs/state-management-in-2026-zustand-vs-jotai-vs-redux-toolkit-vs-signals-2gge) — perf and bundle data; MEDIUM +- [PocketBase / Supabase comparison 2026](https://uibakery.io/blog/supabase-alternatives) — backend trade-offs; MEDIUM +- [Universal Paperclips — Wikipedia + memalign analysis](https://en.wikipedia.org/wiki/Universal_Paperclips) — verified the lineage was vanilla JS, but argument *against* repeating that approach for this scope; HIGH on the historical claim +- [Math of Idle Games (Kongregate)](https://blog.kongregate.com/the-math-of-idle-games-part-iii/) — offline progression / delta-time math foundation; HIGH (industry-standard reference) +- [Cuphead animation pipeline (GDC, Made With Unity)](https://unity.com/made-with-unity/cuphead) — referenced for "watercolor backgrounds + frame-sequence character art" pattern, *not* the engine choice; MEDIUM +- [lz-string for client-side compression](https://pieroxy.net/blog/pages/lz-string/index.html) — verified UTF-16 safety + compression ratios; HIGH +- [Godot 4 HTML5 export size analysis](https://docs.godotengine.org/en/latest/tutorials/export/exporting_for_web.html) — verified ~20–40MB minimum; HIGH +- [Unity WebGL load-time/file-size guidance](https://docs.unity3d.com/6000.3/Documentation/Manual/webgl-performance.html) — verified the size/load problem; HIGH + +--- +*Stack research for: browser-based narrative idle game (The Last Garden)* +*Researched: 2026-05-08*