Files

57 KiB
Raw Permalink Blame History

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 (17), 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.

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/
│   │   └── ... 0307
│   ├── 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:

// 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: 410 Hz simulation tick. A day is then 345k864k iterations, fast enough to catch up in <1s if simulate() is well-optimized, and granular enough that real-time feedback feels responsive.

Example:

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

# 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
// 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<typeof FragmentSchema>;

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:

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

// src/app/persistence/migrations.ts
type Migration = (s: unknown) => unknown;

const migrations: Record<number, Migration> = {
  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 27 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 27 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
01k DAU Single Cloudflare Worker + KV. Plausible free tier. CDN via Vite output to Netlify/Cloudflare Pages. Save in IndexedDB locally.
1k100k DAU Same architecture, scaled regions. Stripe handles its own scaling. Add error monitoring (Sentry). Bundle audit; per-Season chunks become more important.
100k1M 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


Architecture research for: browser-based narrative idle game (The Last Garden) Researched: 2026-05-08