nEngine — top-down 2D farming game engine (not a game). TypeScript + PixiJS. Built around three non-negotiable architectural pillars: MCP-first interfaces, engine-level Mod support, enforced data-driven content.
An engine whose core value is controllability by external tools/agents, extensibility, and content-as-data — not raw rendering performance. The engine provides logic systems; creators provide only data (YAML content files). No engine logic hardcodes content.
item.*, crop.*, map.*, save.*). "MCP for every feature" is an architectural constraint (enforced by the registration layer), not a process count.onDayStart(ctx), onCropHarvest(ctx)); they touch only context objects, never engine internals.| Path | Purpose |
|---|---|---|
| docs/decisions.md | All architecture decisions, with rationale and user corrections. Source of truth for WHY. |
| docs/architecture.md | Top-level architecture, package boundaries, directory layout, core API overview. |
| docs/mcp.md | MCP-first interface spec: per-subsystem namespace tools/resources/prompts. The interface contract. |
| docs/api.md | Engine API reference, organized by MCP service. The living API doc. |
| docs/boundaries.md | Engine/game boundary: what is engine-built vs creator content, with the tri-criterion. |
docs/mcp.md / docs/api.md in the same change. A change without its doc update is incomplete.add schema → add system → register MCP namespace → declare hook points.as any, @ts-ignore, @ts-expect-error).**Walking skeleton + Tier 1 complete; Tier 2 complete (behavior language v2 + dialogue + weather + placement + NPC grid movement + tick loop + animal age + multi-viewport sync); player input built (D25); presentation pipeline complete (D26 data/protocol + D27 render half: sprite drawing + Web Audio playback + keyboard input); deterministic seeded RNG built (D28 — the ONE LÖVE gap adopted); Tier 3 content round complete (D29 — stamina/skills field-level + onPlayerIntent hook + multi-quest chain + fishing minigame Mod); game packaging built (D30 — content bundle + self-contained static site via apps/pack nengine-pack, bundle-mode createEngine({bundle}) + ContentLoader.loadBundle + apps/dev ?bundle= mode; no Mods in packaged output at D30, closed by D36's --mods; no Tauri yet, no pack.* MCP namespace — all documented); asset pipeline built (D31 — binary assets as data-driven content: content/assets/ .png/.ogg files → asset table (registry.assets/engine.assets, base64; NOT a content kind, no MCP namespace), bundle.assets + WS-snapshot assets field (BUNDLE_VERSION stays 1), sprite image layer (src = asset id), map tiles tile-id→asset-id mapping, sound.src audio playback, render texture/buffer caches keyed by asset id with async load + redraw-on-ready; sample-game adopts 13 Kenney tiles + 8 interface sounds); desktop packaging built (D32 — Electron over Tauri: Tauri needs webkit2gtk-4.1 system deps (no sudo here), Electron is npm-only; nengine-pack --desktop [--name <name>] writes out/desktop/ (main.cjs + package.json + README-desktop.md): main.cjs serves the out dir over a local node:http server (127.0.0.1, ephemeral port, GET-only, MIME map, path-traversal guard, exact files only, SITE_ROOT = resolve(__dirname, "..") → any cwd) and loads it in a 960×640 BrowserWindow (title = product name via page-title-updated prevention, contextIsolation: true/nodeIntegration: false); npm i && npm start in desktop/ runs it; no installer/electron-builder, default icon, sidecar headless core (Mods + multiplayer) reserved v2 — all documented; E2E-verified in real Electron via playwright _electron; pnpm pack:example:desktop packages the sample game); core mechanism completions built (D33 — A* grid pathfinding: pure findPath(map, start, goal, isBlocked) (4-neighbor, Manhattan, array-based open set — maps are ~16×16) + MovementSystem.findPath(npcId, to) (blocks = buildings + other grid entities, never moves the npc) + MCP debug npc.path (101st tool); data-driven map walkability: optional MapDef.walkable?: Record<tileId, boolean> with a single isWalkableTile source of truth (declared value wins, else the UNWALKABLE_TILES fallback — existing maps keep water blocked, zero content change) consumed by movement/intent/placement(buildable:"map")/pathfinding; behavior-language add action ({action:"add", path, value} — incremental, missing field = 0, set-state stays absolute, closing the D29 gap); new single-file content kind interact (content/interact.yaml {id, order:[harvest,water,sow,till]} — the player interact priority ORDER is content policy, applicability stays engine mechanism, no MCP tools); save.* covers TimeSystem (optional additive time: {day, season, tickInDay} in WorldSnapshot, version stays 1, TimeSystem.set(day, season, tickInDay=0), restore re-sets the clock); **render-layer completions built (D34 — 5 rendering mechanisms, all render-layer + content-kind only, no new engine systems/hooks: (1) particle content kind — event-triggered bursts via the existing behavior-language emit-event action (event:"particle", payload:{particleId, x?, y?, entityId?}; x/y tile coords, absent → the named entity or player), pure particles.ts (spawn ranges, px/s physics, gravity, expiry, fade approximated by size shrink over lifetime — documented); (2) shader content kind — parameterized BUILT-IN filters (blur/tint/grayscale/sepia/hue/brightness over PixiJS BlurFilter/ColorMatrixFilter), NO GLSL in content; optional SpriteDef.filters + whole-map filter via weather visuals; (3) sprite-sheet support — optional frameX/frameY/frameW/frameH source-pixel crop rects on the sprite image layer → viewport sub-textures (cached by asset+rect); (4) weather visual presentation — pure weather.ts built-in mapping (rain/storm → rain particles + rain-tint filter, wind → wind particles, storm → +overlay color) applied to the map container with periodic particle bursts; (5) entity text labels — plain label entity field (content-able; the mayor spawn carries label:"Mayor"), pure labels.ts position math + Pixi Text in the viewport. MCP 18→20 namespaces (particle.*/shader.*, 5 lifecycle tools each + catalog + {id} resources + create-prompt).** 21 MCP namespaces live (server/item/crop/map/time/shop/save/behavior/dialogue/weather/mod/placement/npc/animal/player/sprite/sound/rng/particle/shader/animation — 116 tools / 34 resources / 15 prompts), 17 content kinds (…particle/shader are the 15th+16th, animation is the 17th), 12 auto-boot systems (Time/Inventory/Planting/Growth/Economy/Save/Trigger/Weather/Dialogue/Placement/Movement/Intent — TriggerSystem is the v2 behavior engine driving both triggers and state machines; WeatherSystem rolls a weighted pick on day-start; DialogueSystem drives authored node/choice documents; PlacementSystem does grid footprint/overlap/buildability from map data; MovementSystem does grid moveTo + blocking + the D33 findPath A* query; GrowthSystem advances both crops and animals, driven by the new engine.tick(dt) tick loop with time-tick intra-day events and optional auto-tick; onSchedule supports at: {tickInDay}; IntentSystem is the single player-input mechanism — handle(action, args) for move/interact (the D33 interact ORDER is content-ized via the interact kind; applicability stays engine mechanism) plus D35's buy/sell/talk/choose/select-seed (economy/dialogue/seed-selection actions; sow prefers player.selectedSeed when it names an owned seed, else the first inventory seed), emitting player-intent per call — behind MCP player.intent, the WS {"type":"intent"} message, and connectLocal.intent), 16 hook points wired (added onStateChange, onDialogueStart, onDialogueEnd, onWeatherChange, onPlacementChange, onAnimalGrow, onAnimalProduce, onPlayerIntent). Seeded RNG (D28): packages/core/src/random.ts RandomSource/seedRandom/randomSeed (mulberry32-style, zero deps, mirroring LÖVE's love.math.RandomGenerator); createEngine({seed?}) exposes engine.rng injected into systems via SystemInitCtx.rng — WeatherSystem day-start rolls and PlantingSystem harvest drop chances draw from it (same seed → identical world randomness); MCP rng.* debug surface (rng.get/rng.set/rng://state); save.* snapshots carry rngState {seed, calls} and restore re-anchors the seed (calls reset, no draw replay). Multi-viewport sync built (D24): apps/mcp-server --ws-port <port>/NENGINE_WS_PORT broadcasts world state over WebSocket (snapshot on connect + {"type":"sync"}, event frames per bus emission — NOT an MCP namespace); packages/render/src/sync/ws.ts connectWs (browser-native, same handle shape as connectLocal); viewport.render(snapshot?) draws map + basic entity shapes (pure shapes.ts geometry — plots/buildings/dots, no art); apps/dev parameterized via ?contentDir=/?modsDir=/?sync=ws&url=ws://.... Presentation data/protocol (D26): two new content kinds sprite (content/sprites/, programmatic draw instructions — rect/circle layers + #rrggbb fills, zod strict, rect/circle/image (D31) layers) and sound (content/sounds/, synth specs — wave/freq/duration/gain/slideTo, zod strict, src reserved, no audio files v1), MCP sprite.*/sound.* (16th+17th namespaces — lifecycle tools mirroring item.*), optional spriteRef appearance hints on crop/animal stages, buildings, and the player (maintained by sow/growth/place/spawn/seed so the snapshot always carries current appearance; missing refs fall back to shape drawing), play-sound emitting the sound-play event {soundId}, the WS snapshot carrying sprites/sounds catalogs and (D31) the assets table, and a {"type":"input","input":string} WS message → input bus event with a built-in key:Arrow* → IntentSystem.move mapping (player-centered, adapter-level). Player input (D25): snapshot frame carries time (HUD day/season on connect); SyncHandle.intent(action, args) is uniform across local + ws (both modes playable); viewport.tileAt(clientX, clientY) converts screen→tile; the dev app has an Interact/Move toolbar + canvas-click intents + HUD (seeds/gold/feedback). Render half (D27): packages/render draws entities by spriteRef (pure sprite-draw.ts — durationTicks-weighted animation clock at a documented default 10 fps, tile-space→pixel command mapping, building footprint scaling; image commands (D31) resolve asset dims from loaded textures + source-crop rects; shapes.ts fallback unchanged for missing refs/unknown ids), map tiles draw via map.tiles → image textures (palette tileColor fallback), sound.ts createSoundPlayer synthesizes sound defs on sound-play (Web Audio, lazy AudioContext behind a user gesture) or decodes src audio assets via decodeAudioData (buffers cached by id; src takes precedence over synth), both sync handles gained input(input) (ws: raw message; local: client-side arrow mapping) and an optional sound player, and apps/dev adds arrow-key movement + audio priming. In-game UI built (D35) — the dev app gains DOM panels wired through the intent mechanism: a seed picker (select-seed intent → player.selectedSeed), a shop panel (discovered from BuildingDef.shop?/building.shop entities + the additive WS-snapshot shops catalog; buy/sell intents), a dialogue panel (visibility from the dialogue:active entity, content from talk/choose intent results), and an inventory bar (gold/stamina/items/seed) — pure display logic in node-tested apps/dev/src/ui.ts, thin DOM wiring in main.ts, both local and ws modes. Mod packaging + browser Mod execution + installers built (D36) — nengine-pack --mods <dir> packages Mods into the bundle (ContentBundle.mods?: Record<modId, {manifest, code}> — additive, BUNDLE_VERSION stays 1; ModLoader-convention scan, per-mod loud failures); ModLoader.loadFromSource(source, id, version?) executes packaged Mod code via a base64 data:text/javascript dynamic import (browser + Node parity, zero fs, same validation/binding as file mods) so bundle mode boots bundle.mods (modsDir wins when both) and apps/dev ?bundle=/desktop need no change; nengine-pack --installer (implies --desktop) generates electron-builder.yml + dist/dist:win scripts + copies site files into desktop/ — Linux AppImage build verified end-to-end (Windows NSIS documented config); pnpm pack:example:full exercises all flags. Scalability fixes built (D37) — WS snapshot assets on-demand + --split-assets: the WS snapshot frame's assets field (D31 — full base64 in EVERY snapshot) is replaced by a metadata-only assetsIndex ({kind, mime, size} — no bytes); new client→server {"type":"assets-request","ids"} (bounded: client AssetCoordinator batches ≤64 ids, server truncates over the cap, unknown ids ignored, empty request → empty response) is answered per-client with {"type":"assets","assets":Record<id,AssetEntry>}; connectWs derives wanted ids from snapshot presentation (pure snapshotAssetRefs — map tiles + sprite image layers + sound.src), dedupes via the pure AssetCoordinator (wanted/loaded/pending), and feeds arrivals into viewport/sound (re-render on ready + setAssets); nengine-pack --split-assets writes out/nengine-assets/<id>.<ext> (one file per asset) + a metadata-only bundle.json (assetsIndex with file paths, no base64) while inline (default) stays byte-identical plus the additive assetsIndex; AssetEntry.data is now OPTIONAL (the engine asset table is a metadata index — bytes are a transport concern; split entries carry size/file); createViewport({assetsBase?})/createSoundPlayer(factory?, assetsBase?) fetch split entries via the pure resolveAssetUrl (pack bakes __NENGINE_ASSETS_BASE__ = "./nengine-assets"; dev ?bundle= derives it from the bundle URL's dir); installer copies nengine-assets/; root script pack:example:split. 727 tests green (D38 adds 35 — core animation-schema 10, mcp animation-namespace 9, render tween 15, client-surface/ws-broadcast/sample-game 1); D37 adds 22 — ws-broadcast 4, render asset-coordinator/refs/url 11, pack 5, core bundle 2; D36 adds 16; D35 adds 25; D34 adds 54; D33 adds 43; D31 added 28); D37 kept the MCP surface at 20/111/32/14 (both fixes are protocol/offline-tool changes — WS transport is NOT an MCP namespace; packaging is an offline build-time tool); D38 raises it to 21 namespaces / 116 tools / 34 resources / 15 prompts (the additive animation.* namespace). Tier 3 content round (D29): stamina/skills are seeded player fields (seedWorld: stamina: 100, skills: {} — no StaminaSystem/SkillSystem, D19); the player-intent bus event gained the onPlayerIntent hook (16th — reverses D25.4's bus-only stance: Mods are hooks-only, D6/D13, and the intent payload is transient, so the fishing Mod can settle the minigame; the ONLY hook whose HookContext.payload is set); the sample-game now chains three quest state machines (starter-quest → carrot-quest → mayor-talk-quest), demonstrates a player.stamina condition + day-start restore (daily-stamina) and the Mod→content bridge (fishing-treat matches mod:fish-caught); the fishing Mod hooks onPlayerIntent (water-tile catch: fish ×1, stamina −5, skills.fishing +1, mod:fish-caught/mod:fish-too-tired events, stamina < 5 → too tired). Known language limitation closed by D33: set-state stays absolute (D20) but the new add incremental action now expresses the mayor-talk-quest gold reward (add player.gold 100 — an increment action deferred in D29 is now built). Tier 2 is fully complete; Tier 3's multiplayer gameplay remains reserved (see below).
onSchedule timing, D20), dialogue/story, weather, buildings/placement, NPC grid movement, tick loop (engine.tick(dt) + time-tick + at: {tickInDay}, D23), GrowthSystem→animal age (animal content kind + fed gate + animal-grow/animal-produce, D23), multi-viewport sync (render/sync, D24 — WS broadcast channel + connectWs client + snapshot-driven viewport drawing). Tier 2 fully complete.animation content kind (content/animations/ — state machine over sprite defs, zod strict; animation.* MCP namespace) drives the render layer's per-entity sprite selection (explicit player.anim field set/cleared by the behavior language, or walk/idle motion inference, or initial) with per-state fps/loop; smooth movement is a render-layer tween of consecutive tile coords (MOVE_DURATION_MS 250 ms, easeOutQuad) over the rAF loop — the engine stays the authoritative instant grid-jump (moveTo/IntentSystem.move untouched); no new systems/hooks. Multiplayer gameplay remains reserved (the multi-viewport sync mechanism is built, D24; gameplay rules are creator content).apps/pack nengine-pack compiles content → bundle.json + static site; createEngine({bundle}) + loadBundle are the engine mechanisms; apps/dev ?bundle= mode reuses the same UI. Desktop (D32): nengine-pack --desktop writes a runnable Electron shell (out/desktop/, npm i && npm start) over the same static site. Mods in packaged output, desktop installers (electron-builder), a sidecar headless core, and a pack.* MCP namespace are all documented future paths (not built).