architecture.md 21 KB

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:

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 <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.
  • 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 <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.

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:<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).
  • 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).