# 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).