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.
┌──────────────────────────────────────────────────────────────┐
│ 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 | 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.
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
content/crops/parsnip.yaml).The MCP surface only ever sees normalized JSON (D18). YAML is purely the authoring surface.
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.
Engine internals are designed to serve the MCP surface (MCP-first, see mcp.md). The facade:
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 }.Every new subsystem is a plugin (D12):
packages/core/src/schema/).packages/core/src/systems/).packages/mcp/src/namespaces/). Write mcp.md section first.| 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.
apps/dev — Vite serves the browser viewport; a sidecar Node process hosts core + MCP gateway (stdio for the agent toolchain, HTTP for runtime debug).apps/mcp-server — node entry, core + MCP, no render. Used for CI, tests, agent-driven content creation.apps/pack nengine-pack --content <dir> [--out <dir>] [--desktop] [--name <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.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 optionsThe 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 <path> / --mods-dir <path> / --ws-port <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:
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.
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).
{"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).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).{"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:<x>:<y>"): 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).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.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.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.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.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).