# Architecture Top-level architecture for the farming engine. See `decisions.md` for the WHY behind every choice here; `mcp.md` is the interface contract (MCP-first); `api.md` maps engine internals to that contract. ## System Topology ``` ┌──────────────────────────────────────────────────────────────┐ │ Headless Node Core (authority) │ │ │ │ Content (YAML) ──► Loader ──► Normalize ──► zod │ │ │ │ ┌──────────────┐ │ │ World state ◄────── │ Systems │ (pure functions │ │ │ (light ECS) │ over entity data) │ │ └──────────────┘ │ │ ▲ ▲ ▲ │ │ │ Hooks │ │ Mod registry │ │ ┌───┴────┐ ┌────┴───┐ ┌─┴────────┐ │ │ │ Mods │ │ MCP │ │ Content │ │ │ │ (Hook) │ │ Gateway│ │ Registry │ │ │ └────────┘ └────┬───┘ └──────────┘ │ └──────────────────────────┼───────────────────────────────────┘ │ stdio / streamable HTTP ┌────────────┴─────────────┐ │ │ AI agents / dev tools Browser viewport (PixiJS) (creation + debug) (render-only, WebSocket) ``` **Authority flow**: content data defines the world → core simulates it → MCP exposes it to agents, WebSocket syncs it to the browser. The browser never owns state; it renders snapshots. ## Package Boundaries (monorepo) | Package | Responsibility | DOM deps | Runtime | |---|---|---|---| | `@nengine/core` | World simulation, content loader, systems, entity store, Mod loader, hook registry | none | Node / browser (headless) | | `@nengine/render` | PixiJS adapter: world → sprites (spriteRef drawing + shape fallback, D27), Web Audio playback, viewport sync + input over WebSocket | yes | Browser | | `@nengine/mcp` | Central MCP gateway, namespace registration layer, transport (stdio + HTTP) | none | Node | | `apps/dev` | Vite dev app: boots core + render + MCP server together | yes | Browser + Node | | `apps/mcp-server` | Standalone stdio entry: headless core + MCP gateway + optional WS world-sync channel, no render | none | Node | | `apps/pack` | **Packaging CLI (`nengine-pack`)**: compiles creator content (YAML) into a content bundle (`bundle.json`) + a self-contained static site (D30) | none | Node (build-time) | | `content/` | **Example/authoring content**: YAML data files, not code | — | — | Tooling: pnpm workspaces, TypeScript strict, vitest. Zod for schema validation. ## Directory Layout ``` Engine/ ├── AGENTS.md ├── docs/ │ ├── decisions.md # WHY (source of truth) │ ├── architecture.md # this file │ ├── mcp.md # MCP interface contract │ └── api.md # engine API reference (living) ├── content/ # YAML content (authoring surface) │ ├── items/ │ ├── crops/ │ ├── maps/ │ └── ... ├── packages/ │ ├── core/ │ │ ├── src/ │ │ │ ├── engine.ts # Engine facade │ │ │ ├── world/ # entity store, world state │ │ │ ├── content/ # YAML loader → normalize → validate │ │ │ ├── systems/ # Time, Planting, Inventory, ... │ │ │ ├── mods/ # Mod loader, hook registry, ctx types │ │ │ ├── schema/ # zod schemas (normalized JSON shape) │ │ │ └── registry/ # per-subsystem registration layer │ │ └── test/ │ ├── render/ │ │ └── src/ │ │ ├── viewport.ts # PixiJS app bootstrap │ │ ├── sync/ # WebSocket state sync │ │ └── sprites/ # entity → sprite adapters │ └── mcp/ │ ├── src/ │ │ ├── gateway.ts # central gateway │ │ ├── namespace.ts # registration contract │ │ ├── transport/ # stdio, streamable HTTP │ │ └── namespaces/ # one dir per subsystem namespace │ └── test/ └── apps/ ├── dev/ # Vite: core + render + MCP └── mcp-server/ # stdio entry, headless ``` ## Data Flow 1. **Author**: creator writes YAML content (`content/crops/parsnip.yaml`). 2. **Load**: core's content loader reads YAML, normalizes to internal JSON shape. 3. **Validate**: zod schema validates the normalized JSON (strict; content cannot smuggle code). 4. **Register**: validated content lands in the Content Registry, keyed by id. 5. **Use**: systems read registry entries + entity data; Mods hook system boundaries. 6. **Hot reload**: YAML change → re-load → re-validate → re-inject; world state preserved. The MCP surface **only ever sees normalized JSON** (D18). YAML is purely the authoring surface. ## Presentation Data Flow (D26/D31) The presentation pipeline is split at the headless/render boundary (D1/D8): the **engine owns presentation data and events**; the render layer owns drawing and playback (round 2 — **complete, D27**). The flow: ``` Author (YAML) ──► Loader ──► zod ──► Registry (sprite.* / sound.* content kinds) │ Author (content/assets/*.png|ogg) ─────┼─► asset table (registry.assets, D31: │ id = filename w/o ext; base64 bytes │ (fs/inline) OR metadata {size, file} │ (split bundles, D37)) ▼ systems set entity spriteRef ────────► snapshot {world, sprites, sounds, assetsIndex} play-sound action ──► sound-play {soundId} + event frames (input, player-intent, ...) │ client input {"type":"input"} ─────────┘ key:Arrow* → IntentSystem.move (adapter) ▼ Browser viewport (render layer, D27/D31/D37): draw spriteRef frames from the catalog (sprite-draw.ts, weighted 10 fps animation clock, building footprint scaling); image layers draw via PixiJS textures (data: base64 URLs, or fetched from the assetsBase for split entries — D37; cache by asset id, async load + redraw-on-ready); map tiles draw from map.tiles → image assets (palette fallback); play sound defs on sound-play (synth OR src → decodeAudioData, buffers cached by id); arrow-key keyboard input via sync.input ``` The contract between the two halves is: **entity `spriteRef` fields** (which sprite a crop stage / building / animal / the player is), the **`sprites`/`sounds` catalogs on the snapshot**, the **`assetsIndex` on the snapshot** (D37 — a metadata-only `{kind, mime, size}` index; the viewport requests the base64 bytes it needs via a WS `assets-request` and receives them in a `{type:"assets"}` frame, and in split-bundle mode fetches them from the assets base — bytes are a transport concern, not snapshot payload), the **`sound-play`** event (playback trigger), and the **`input`** bus event (raw input, broadcast-only unless the built-in arrow mapping applies). A missing `spriteRef` def or a missing image asset means the renderer falls back to its existing shape drawing — the pipeline degrades gracefully. ## Core API Overview (facade) Engine internals are designed to *serve* the MCP surface (MCP-first, see `mcp.md`). The facade: ```ts const engine = createEngine({ contentDir: "content" }); await engine.start(); // load content, validate, boot systems engine.world; // World: entity store + queries engine.registry; // Content Registry: item/crop/map lookups engine.hooks; // Hook Registry: onDayStart(ctx), ... engine.mcp; // MCP gateway handle (register namespaces) engine.systems; // TimeSystem, PlantingSystem, ... await engine.stop(); // flush, persist if policy says so ``` Key interfaces (full signatures in `api.md`): - `System` — `init(ctx)`, `step(dt)`, optional `on(event, payload)`; pure functions over entity data. - `Entity` — flat schema-validated object: `{ id, type, ...componentFields }`. - `HookContext` — the ONLY object Mods touch; exposes a curated, read-mostly surface (world queries, event emit). Mods never import engine internals (D6). - `McpNamespace` — the registration contract every subsystem must fulfill (D9): `{ name, tools, resources, prompts }`. ## Subsystem Template Every new subsystem is a plugin (D12): 1. **Add schema** — zod schema for its content + entity component fields (`packages/core/src/schema/`). 2. **Add system** — pure-function system operating on entity data (`packages/core/src/systems/`). 3. **Register MCP namespace** — tools/resources/prompts under its namespace (`packages/mcp/src/namespaces/`). **Write `mcp.md` section first.** 4. **Declare hook points** — typed hooks Mods can register against. ## Tier 1 Subsystems | Subsystem | Content schema | Core system | MCP namespace | Hook points (initial) | |---|---|---|---|---| | Map/terrain | `map` YAML (tiles, spawn) | World/terrain access | `map.*` | — | | Time | `time` YAML (day length, seasons) | `TimeSystem` | `time.*` | `onDayStart`, `onSeasonChange` | | Planting | `crop` YAML (stages, water needs) | `PlantingSystem`, `GrowthSystem` | `crop.*` | `onCropHarvest`, `onCropGrow` | | Items/inventory | `item` YAML (tools, seeds) | `InventorySystem` | `item.*` | `onItemAcquire`, `onItemUse` | | Economy | `shop` YAML (buy/sell prices) | `EconomySystem` | `shop.*` | `onTransaction` | | Persistence | (runtime snapshot) | save/load + migrations | `save.*` | — | | Behavior language | embedded conditions/triggers | `TriggerSystem` | `behavior.*` | `onTriggerFire` | | Mods | `mod` manifest | Mod loader | `mod.*` (introspection only) | — | > `mod.*` MCP namespace is **introspection only** (list/status). No Mod↔MCP bridge (D10): MCP never invokes Mod-registered behavior, Mods never register MCP tools. ## Transport & Deployment - **Dev**: `apps/dev` — Vite serves the browser viewport; a sidecar Node process hosts core + MCP gateway (stdio for the agent toolchain, HTTP for runtime debug). - **Headless**: `apps/mcp-server` — `node` entry, core + MCP, no render. Used for CI, tests, agent-driven content creation. - **Packaged game (D30/D32)**: `apps/pack` `nengine-pack --content [--out ] [--desktop] [--name ]` compiles a game's content (YAML) into a self-contained static site — `out/index.html` + `out/assets/` + `out/bundle.json` — that runs in the browser via the engine's bundle mode (`createEngine({bundle})` + LocalSync). The bundle is the single source of truth for packaged content (no YAML ships; YAML parsing happens only at compile time). Mods are NOT bundled (server/authoring feature; browser module-import restrictions) — documented limitation. **Desktop (D32)**: with `--desktop`, the same command writes an **Electron shell** into `out/desktop/` (`main.cjs` + `package.json` + `README-desktop.md`) — a local HTTP server (127.0.0.1, ephemeral port) serving the out dir + a 960×640 `BrowserWindow` loading `index.html` (`cd out/desktop && npm i && npm start`). Electron was chosen over Tauri (Tauri needs `webkit2gtk-4.1` system deps; Electron is npm-only). No installer/electron-builder in v1; a sidecar headless core (Mods + multiplayer via the D24 WS channel) is a documented future path. - **Desktop sidecar core** (later, optional): headless core packaged alongside the shell, talking to the viewport over WebSocket (D7/D8/D24) — reserved for Mods + multiplayer in packaged output. ### Packaging Pipeline (D30) ``` content/ (YAML) ──► nengine-pack ──► out/ ├── bundle.json # {format:"nengine-bundle",version:1,content:{kind:[defs]}, assets|assetsIndex} ├── index.html # the built apps/dev app, defaults to the bundle (no URL params) ├── assets/ # JS (pixi + engine + dev app) ├── nengine-assets/ # D37: --split-assets → one file per asset (no base64 in bundle.json) └── desktop/ # D32: Electron shell (main.cjs + package.json + README) │ served statically (npx serve / python http.server) ▼ browser: fetch bundle.json → createEngine({bundle}) → loadBundle (re-validate per kind) → LocalSync → full playability desktop/ (D32): main.cjs starts a node:http server (127.0.0.1, ephemeral port) on the out dir and opens it in a BrowserWindow (960×640, contextIsolation on, nodeIntegration off) — the same bundle-mode page, zero Node access from the page. npm i && npm start. ``` `nengine-pack` steps: (a) fs-load + validate the content dir via the core `ContentLoader` (per-item zod; any error → per-kind/per-id messages, exit 1); (b) shape `bundle.json`; (c) programmatic vite build of the `apps/dev` app with the baked bundle default (`__NENGINE_BUNDLE_DEFAULT__`) and `base: "./"` into the out dir; (d) write `bundle.json`; (e) with `--desktop`, `generateDesktop` writes the Electron shell into `out/desktop/`. The dev app reuses the SAME UI in `?bundle=` mode (fetch the bundle over HTTP, fs shim bypassed). The packaged game is created-content-only — the engine provides the mechanism, the game is just data; no gameplay logic in the pack tool. ### `apps/mcp-server` entry options The stdio entry serves **any** content/mods pair, not just the monorepo's own `content/`/`mods/`. Roots resolve in precedence order CLI > env > default; an optional WebSocket world-sync channel (D8/D24) starts in the same process: | Source | Flag / var | Default | |---|---|---| | CLI | `--content-dir ` / `--mods-dir ` / `--ws-port ` | — | | Env | `NENGINE_CONTENT_DIR` / `NENGINE_MODS_DIR` / `NENGINE_WS_PORT` | — | | Default | — | `content/`, `mods/` at the monorepo root; WS channel off | Relative CLI/env paths resolve against `process.cwd()`. Zero-arg invocation behaves exactly as before (serves the monorepo's own content, no WS channel). Example — serve the sample game with a viewport channel: ```sh node apps/mcp-server/dist/index.js \ --content-dir examples/sample-game/content \ --mods-dir examples/sample-game/mods \ --ws-port 8787 ``` The wiring (`resolveDirs(argv, env, cwd)` → `createEngine` → `createNengineMcpServer` + `startWsBroadcast`) is exported from `apps/mcp-server/src/index.ts` and auto-runs on import only when the module is executed directly, so tests can drive it in-process. ## Sync Layer (D8/D24/D25) Headless core authoritative; browsers are render viewports synced over WebSocket. A single `apps/mcp-server` process serves BOTH surfaces: the MCP stdio gateway (agents create/debug content) and, when `--ws-port` is set, a WebSocket broadcast server (`apps/mcp-server/src/ws-broadcast.ts`) that subscribes to the engine's event bus (via the facade's `engine.events`). - **Snapshot**: on connect (and on a `{"type":"sync"}` client message) the server sends `{"type":"snapshot","map":MapDef,"world":Entity[],"time":{day,season,tickInDay},"sprites":SpriteDef[],"sounds":SoundDef[],"assetsIndex":...}` — the map def included because viewports need it to build tiles; world entities alone lack it; the `time` field (from `TimeSystem.get()` at broadcast time, D25) lets viewport HUDs show day/season immediately instead of waiting for an event frame; the `sprites`/`sounds` catalogs (from `registry.list` at snapshot time, D26) carry the programmatic draw/audio instructions the viewport needs to present the world in-band (no second request); the **`assetsIndex`** (D37) is metadata-only `{kind, mime, size}` — bytes travel on demand via a `{"type":"assets-request", ids}` message answered with a `{"type":"assets"}` frame (see `docs/mcp.md`). - **Events**: every `WorldEventBus` emission is broadcast to ALL connected clients as `{"type":"event","event":string,"payload":unknown}`. Payloads are JSON-serialized defensively (non-serializable payloads are skipped with a log, never a crash). - **Input (D25/D26)**: a client may send `{"type":"intent","action":"move"|"interact","args":{tile:{x,y}}}`. The server parses it, calls `IntentSystem.handle(action, args)` (the same mechanism MCP `player.intent` and `connectLocal.intent` use), and the resulting `player-intent` bus emission is broadcast as an event frame. Malformed input never crashes the server — a synthesized `player-intent` error frame is sent instead. A client may also send `{"type":"input","input":string}` (D26) — a **raw input string** (`"key:ArrowUp"`, `"key:Space"`, `"pointer:click::"`): the server emits an **`input`** bus event `{input}` broadcast as an event frame (behavior-language-matchable `on:"input"`), and maps `key:ArrowUp`/`ArrowDown`/`ArrowLeft`/`ArrowRight` to `IntentSystem.handle("move", {tile})` for the tile one step from the player's **current** position (player-centered; the mapping is adapter-level — the `handle` mechanism is untouched, D25). Unknown keys/prefixes are broadcast-only; a non-string/empty `input` never crashes (a synthesized `input` error frame is broadcast instead). - **Client**: `packages/render/src/sync/ws.ts` `connectWs({url, viewport, onEvent?, onSnapshot?, sound?})` — browser-native `WebSocket`, same handle shape as `LocalSyncHandle` plus `intent(action, args)` (sends the intent message) and `input(input)` (sends the raw `{"type":"input",input}` message), auto-reconnect, time label derived from `snapshot.time` AND `day-start`/`time-tick`-style event payloads, fresh-snapshot re-sync after each event frame. The optional `sound` player (D27) is fed the `sounds` catalog from every snapshot and receives `sound-play` frames directly. - **Drawing (D27)**: `viewport.render(snapshot?)` draws ground + spawn marker from the map def, then per entity: a resolvable `spriteRef` draws the referenced sprite frame (see `sprite-draw.ts` — pure frame/draw-command mapping; plots keep their shape base with the sprite on top, other sprite entities replace their shape); otherwise the `shapes.ts` shape fallback (plots tilled/watered/crop-stage, buildings as footprint blocks, npc/player/animal dots) — absent snapshot → map-only, the walking-skeleton behavior. Animated sprites (>1 frame) advance on a renderer-side `requestAnimationFrame` clock where `durationTicks` weights each frame against a documented default 10 fps; buildings draw their sprite scaled to the entity's `w`×`h` tile footprint. `viewport.tileAt(clientX, clientY)` converts screen coordinates to a tile (accounting for the centered map offset + 32px tile size; `null` outside the map) for canvas-click input. - **Sound (D27)**: `sound.ts` `createSoundPlayer` synthesizes `sound` defs on `sound-play {soundId}` frames (Web Audio `OscillatorNode`; wave/freq/durationMs/gain + `slideTo` ramp). The `AudioContext` is created lazily on the first play or `prime()` call from a user gesture (browser autoplay policy); playback is headless-silent and errors are swallowed. - **Local sync**: `connectLocal(engine, viewport, {sound?})` renders a full snapshot (map + entities + both catalogs) from the engine on refresh, delegates `intent(action, args)` straight to the engine's `IntentSystem`, maps `input(input)` arrow keys client-side (`arrowDirection` → one tile from the player → `intent("move", ...)`), and feeds the sound player from the engine bus — so the dev app is playable in BOTH local and ws modes with one UI. - **Dev app**: `apps/dev` is parameterized via URL query — `?contentDir=`/`?modsDir=` (default repo `content/`/`mods/` + LocalSync), or `?sync=ws&url=ws://localhost:8787` which connects to a headless core instead of booting one in the browser. An Interact/Move toolbar selects the tool; clicking the canvas sends `tileAt → sync.intent`; arrow keys send `sync.input("key:Arrow<…>")` (moves the player in both modes); `pointerdown`/keydown prime the audio context; the HUD shows day/season, seed counts + gold (from the player entity), and auto-clearing intent feedback (from `player-intent` event detail).