AGENTS.md 40 KB

AGENTS.md

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.

What This Project Is

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.

Non-Negotiable Pillars

  1. Every subsystem exposes MCP — a central MCP gateway with per-subsystem namespaces (item.*, crop.*, map.*, save.*). "MCP for every feature" is an architectural constraint (enforced by the registration layer), not a process count.
  2. Engine-level Mod support — Mods register to typed hook points (onDayStart(ctx), onCropHarvest(ctx)); they touch only context objects, never engine internals.
  3. Data/logic separation, enforced — game creation happens only through data. Content files (YAML) contain no code; engine logic contains no hardcoded content.

Design Principles (derived from docs/decisions.md — READ IT)

  • MCP-first: when designing any interface, design the MCP surface first (tools/resources/prompts), then derive the engine API from it. Engine internals exist to serve the MCP surface.
  • Engine provides mechanisms, never strategies (save format is a mechanism; save policy is creator content).
  • Mechanisms over policies; declarative data language over code; Hooks over deep imports.
  • Mods serve gameplay only. MCP serves engine-native capabilities only. No Mod↔MCP bridge.
  • Headless Node core is the authority; browsers are render viewports.
  • Desktop-first (D40): distribution target is desktop (Electron, unchanged Web stack). Browsers/WS/static-site remain as dev/debug aids — kept, no longer extended.

Document Map (keep in sync)

| 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/editor.md | Editor architecture (D40): D40 decision summary, apps/editor structure, main/preload/renderer responsibilities, IPC API table, project.yaml schema, template contents, round roadmap E1–E7 + F1–F2. |

Work Conventions (MANDATORY)

  1. MCP-first design: when adding or changing a subsystem, write its MCP namespace spec (tools/resources/prompts) before implementing engine internals. The engine API must be derivable from the MCP surface.
  2. API docs are living documents: ANY change to an interface (MCP tool signature, engine API, data schema) MUST be reflected in docs/mcp.md / docs/api.md in the same change. A change without its doc update is incomplete.
  3. Every new subsystem follows the plugin template: add schema → add system → register MCP namespace → declare hook points.
  4. Content data is authored in YAML, validated after normalization to internal JSON (zod). The MCP surface only ever sees JSON.
  5. Data hot-reload on content change; code Mod changes restart the headless core.
  6. Type safety: never suppress errors (as any, @ts-ignore, @ts-expect-error).

Current Status

**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).** 23 MCP namespaces live (server/item/crop/map/time/shop/save/behavior/dialogue/weather/mod/placement/npc/animal/player/sprite/sound/rng/particle/shader/animation/scene/craft — 132 tools / 36 resources / 17 prompts), 18 content kinds (…particle/shader are the 15th+16th, animation is the 17th, scene is the 18th), 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); D40 foundation raises it to 21 namespaces / 119 tools / 34 resources / 16 prompts + 736 tests green (the shared-authoring extraction refactors all twelve authoring namespaces onto packages/mcp/src/authoring.ts with zero behavior change, and map.* gains validate/create/update + map.create-prompt — D40.4's map-writing capability; the D40 shared authoring layer is BUILT, the GUI editor consumes validateDef/writeContentFile/updateContentFile directly); D40.9 scene round raises it to 22 namespaces / 124 tools / 36 resources / 17 prompts + 758 tests green (the scene content kind — content/scenes/<id>.yaml initial-entity list, zod strict discriminated union — is instantiated at seed time by a new seedScenes engine step AFTER initSystems/BEFORE Mods: buildings via PlacementSystem.place with overlap/buildability validation, NPCs as entities with dialogue/label, animals via GrowthSystem.spawnAnimal, all using the runtime entity id formats; bad references/conflicts/duplicate npc ids FAIL boot loudly; the scene.* MCP namespace (22nd) goes through the shared authoring layer + scene.list/scene.get + catalog/{id} resources + scene.create-prompt; no scene content added to examples/sample-game or content/ — demonstration lives in tests + docs). 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). Editor design locked (D40); foundation built (D40); apps/editor scaffolded (D40/E1) — a desktop-first graphical creation tool: a single Electron app, editor + runner in one (visual map editing, scene placement, schema-driven content forms, embedded playtesting, embedded MCP server for AI-assisted design sharing one engine instance + the shared authoring layer — packages/mcp/src/authoring.ts, extracted from the MCP creation namespaces and already BUILT (all twelve authoring namespaces refactored onto it with zero behavior change, and map.* gained validate/create/update + map.create-prompt so visual map editing can write map YAML through the same path)), packaging via nengine-pack; new scene content kind (initial entity list, seeded at engine start); implemented as a new apps/editor package in vertical slices — **E1 (project layer) BUILT (2026-08-13): the package scaffold + Electron main/preload/renderer, project OPEN/NEW (built-in minimal playable template boot-validated by createEngine)/RECENT-OPEN, project.yaml read-write, project:listContent/project:stats, 10 strictly-typed IPC channels, 28 new node tests (786 total green); E2 (map editing) BUILT (2026-08-13): a pure src/renderer/map-model.ts (createMapModel/paint/erase/setSpawn/toggleWalkable/addTileId/serialize/tileColor/collectTileIds — deep-cloned working def, grid ops never mutate the input, serialize re-validates via mapSchema.safeParse and rejects invalid edits, erase=grass→first-palette→"", spawn non-integers rejected + out-of-range clamped, walkable toggle sets false / deletes key to restore the D33 fallback, editor palette colors NOT real assets) + a thin 32px canvas-2D map-editor.ts DOM shell (paint/erase/spawn/walkable tools, right-click erase, palette column + add-tile-id, red-hatch walkable viz, Save = serialize → stringify → project:writeYaml with dirty-state button; no map resize in v1); the editor renderer imports runtime core via editor-only synthetic facets @nengine/core-schema/@nengine/core-systems (the core barrel is browser-unsafe — loader/mods use node:fs), 31 new node tests (817 total green); E3 (content forms) BUILT (2026-08-13): a schema-driven universal form generator — pure src/renderer/form-schema.ts (deriveFormSchema walks zod _def defensively → recursive FormSchema: field/label/type string|number|boolean|enum|ref|object|array|union, optional flags, enum values, array elements, discriminatedUnion branches + discriminator, cross-kind reference heuristics — names ending Ref/Id/Src/SeedItem → their kind, e.g. spriteRef→sprite ids; heuristic table extensible; unsupported constructs reported not mis-rendered) + pure form-value.ts (initialValue defaults / applyValue lossless overlay from the loaded def so unknown fields survive save / coerceForValidate) + thin DOM form-panel.ts (recursive: text/number/checkbox/select/ref-dropdowns fed by per-kind id lists via IPC, nested fieldsets, add/remove/reorder array editors, union-branch select; Save = merge → contentSchemas[kind].safeParse via the @nengine/core-schema facet → stringify → project:writeYaml; zod issues in status bar; per-kind + (new) create flow, duplicate-id error, id first and read-only when editing) — all 18 kinds formable, schema changes need no form code; 53 new node tests (870 total green)**; scene placement editor built (E4 — 2026-08-13): the scene kind opens a placement editor over the SAME canvas as the E2 map editor with an Edit Scene/Edit Map mode toggle (one shared canvas, map from the project's first map file, each model with its own dirty state, one Save writes both); pure src/renderer/scene-model.ts (createSceneModel + building/npc/animal CRUD with footprint-aware overlap — unknown building ids, duplicate building-at-coords, duplicate npc ids, non-integer/out-of-bounds coords, and any footprint overlap are REJECTED since seedScenes fails boot on placement conflicts, deliberately stricter than seed-time so authored scenes always boot; npc/animal are 1×1, buildings use a footprint table read from the project's building defs; serialize re-validates via sceneSchema.safeParse → null on invalid, invalid never writes; empty scenes valid) + thin DOM scene-editor.ts (building/animal palette with footprint tooltips, npc free-id form with optional dialogue/label, click-to-place, entity list with select/delete/click-to-move, Save = serialize → stringify → project:writeYaml to content/scenes/<id>.yaml; entities render as plain colored shapes — NO asset rendering in v1, playtest territory E5); 43 new node tests (934 total green — 913 + 21 editor playtest tests); **embedded MCP server built (E6 — 2026-08-13, D40 #5/#16): the editor embeds an in-process MCP gateway serving the editor's OWN engine instance (same contentDir + registry + shared authoring layer) — created on project open (createEditEngine(root) = createEngine({contentDir, modsDir?}) full fs mode in main), stopped on switch/close/quit (project:close IPC added); streamable HTTP primary (persistent node:http server on 127.0.0.1, ephemeral port, /mcp GET-SSE/POST/DELETE + OPTIONS CORS preflight via the SDK StreamableHTTPServerTransport; URL stable for the whole session, top bar shows it with a Copy MCP URL button; attach/detach swap the gateway+transport per project) + --mcp-stdio headless secondary (node apps/editor/dist/main/mcp-stdio.js --dir <project> pure-Node entry or electron . --mcp-stdio --dir <project> — the editor binary as a drop-in stdio MCP server, mirroring apps/mcp-server); dual-channel same world (decision #16, full exposure — no permission narrowing): AI edits via MCP write disk + registry through the shared authoring layer → GUI reads disk; GUI saves (project:writeYaml) re-register into the engine registry via syncContentWrite → MCP sees them immediately; the GUI never goes through the MCP protocol, so namespace toggling by an AI client can't destabilize it. Same 23 namespaces / 132 tools / 36 resources / 17 prompts wired to the editor's instance. New deps @nengine/mcp + @modelcontextprotocol/sdk (main-process-only); 14 new node tests (948 total green — 934 + 14: mcp-server HTTP +12, mcp-stdio spawn +2, real SDK clients over both transports); **package dialog built (E7 — 2026-08-13, D40 #8: a Package button in the editor top bar opens a small DOM dialog that runs the existing nengine-pack pipeline in main — pack:run IPC → runEditorPack (src/pack.ts) → packGame from @nengine/pack (library call not CLI, stateless like E5's compileBundle; packGame exposes no progress callbacks, so pack:run is a single blocking call with a dialog busy state — documented) — fields (product name, icon path, desktop, installer, include Mods, split assets, output directory default <root>/dist-pack) pre-filled from project.yaml (D40.6 packaging metadata: product name = pack.name → top-level name → display-name chain; pack.{installer,mods,splitAssets}; icon) and saved back into project.yaml on run (persistent fields only; desktop/outDir stay transient run options, installer implies desktop); the merge is a pure shared module src/pack-options.ts (dialogDefaultsFromProject/projectYamlFromPack/resolvePackRequest/packHints, zero node imports) used by both the renderer dialog (renderer/pack-dialog.ts) and the main step; success shows the output path + hints (npm install && npm run dist in desktop/ for installers), failures return {ok:false,error} — content compile failures with per-def messages, vite build errors, desktop/installer generation errors (installer mode only writes electron-builder.yml, so a missing electron-builder surfaces at npm run dist time — documented); icon is stored in project.yaml only — the v1 pipeline still ships the default Electron icon (D32.6), pack logic untouched; Mods package only when the dialog asks AND <root>/mods exists; output layout = the unchanged pipeline (index.html + assets/ + bundle.json, nengine-assets/ when split, desktop/ for desktop/installer); 13 new node tests (editor 190 → 203: pack-options merge 9 + real runEditorPack over a mkdtemp fixture 4 — site, desktop shell, installer config + site copy, compile-failure error) — 948 → 961 total green)**; **F1 Mod management built (2026-08-13 — D41 #2: project.yaml gains optional mods: {enabled?: string[]} (absent = all enabled, backward compatible; read/write round-trips losslessly); createEngine gains optional enabledMods?: string[] (undefined = all) with the filter applied INSIDE ModLoader.load/reload — a disabled Mod never binds and never appears in engine.mods/reloadMods results, unknown ids ignored; bundle mode (bundle.mods) filters too; facade exposes engine.enabledMods; new inspectModEntry(dir) reads a Mod's manifest + hook names WITHOUT binding (the panel's read of disabled mods); @nengine/pack compileBundle/compileMods/packGame gain ADDITIVE optional enabledMods (4th param / PackArgs field — no CLI flag, editor is the only caller, zero signature breaks); the editor sidebar gains a non-kind Mods entry (count = mods/ dirs) → panel (pure renderer/mods-model.ts toggle logic + thin mods-panel.ts DOM: per-mod id/version/hooks/load-state + Enable/Disable) that persists mods.enabled via project:writeProjectYaml on EXPLICIT toggle only (never on mere open) then calls the new mods:reload IPC — main stops + RECREATES the edit engine with the new filter (a createEngine option; the reload path cannot change it — documented); playtest:start and pack:run pass the list to compileBundle/packGame so playtest + packaged games honor the SAME list (zero semantic divergence); mods:list IPC merges the engine's live ModStatus[] with inspectModEntry reads so disabled mods still show id/version/hooks; MCP surface UNCHANGED 23/132/36/17 (mod.* stays introspection-only, D10); tests +6 core / +2 pack / +13 editor → 961 → 982 total green)**; **F2 Asset management built (2026-08-13 — D41 #3: an Assets non-kind sidebar entry → assets-panel.ts** (image thumbnails via data URLs + audio rows, Import… through a main-side native file dialog, confirm-guarded Delete, empty state; pure renderer/assets-model.ts split/merge/remove/size helpers) behind three new main-owned IPC channels — assets:list (fresh content/assets/ scan: {id, kind image|audio, mime, size, dataUrl?}, dataUrl images-only), assets:import ({root, sourcePaths?} — explicit path list OR main-side dialog; copies into content/assets/ with a sanitized lowercased id and -2/-3 dedupe on basename collision, never overwrites, per-file rejection for unsupported extensions), assets:delete (unlink by id, {ok}/{ok:false, error}); project:listContent assets now filtered to .png/.ogg (isSupportedAssetName) so form dropdowns + counts match the engine's asset table; registry refresh is a main-side re-read (refreshEditEngineAssets/readAssetEntries in src/assets.ts, D31-parity base64 entries) since the D31 ContentLoader.watch() is a mechanism the engine never wires — zero core changes, D31's "no asset MCP namespace / not a content kind" stands; form reference dropdowns include asset ids: sprite image layer src/sound.src already resolved to the asset ref kind, and the F2 gap map.tiles record values (tile id → asset id) now resolve to an asset ref via ASSET_RECORD_VALUE_FIELDS in form-schema.ts (kept OUT of REF_KIND_BY_NAME because building.tiles is an array of footprint tile ids — only record values get the marker), with the free-text fallback preserved (no assets → text input; unlisted ids selectable); refIdList exported for tests; MCP surface UNCHANGED 23/132/36/17; tests +15 (editor 216 → 231: assets 8, assets-model 3, form-ref-assets 4) → 982 → 997 total green)**; **editor distributed as a desktop app (F4 built 2026-08-13 — D41 #4/#6: apps/editor gains its own electron-builder config (appId com.nengine.editor, productName "nEngine Editor", output dist-app/ — separate from dist/ so emptyOutDir never wipes build artifacts; linux AppImage + win nsis; executableName: nengine-editor — AppImage rejects the @ derived from @nengine/editor) + dist/dist:win/build:dist scripts; workspace deps are auto-collected by electron-builder v26 following the pnpm symlinks (verified: @nengine/core/@nengine/mcp/@nengine/pack dist, @modelcontextprotocol/sdk, yaml, zod, transitive vite via @nengine/pack) — @nengine/render moved to devDependencies (build-time only: vite bundles it into the renderer, main never imports it → no pixi.js in the package) and @modelcontextprotocol/sdk moved to dependencies (runtime dep of the embedded MCP server); latent sandboxed-preload bug fixed (Electron sandboxed preloads cannot require relative modules — the preload now bundles to a single self-contained CJS file via vite.preload.config.ts, tsc.preload noEmit type-check, sandbox/contextIsolation stay on); real Linux AppImage verified end-to-end (NODE_OPTIONS=--use-system-ca pnpm --filter @nengine/editor dist → dist-app/nEngine Editor-0.1.0.AppImage 138 MB; launch smoke with ELECTRON_DISABLE_SANDBOX=1 DISPLAY=:1: renderer + embedded-MCP loopback servers, home screen served 200 with the nEngine Editor/#btn-open DOM, renderer process running against the mounted asar, zero bridge errors; packaged --mcp-stdio --dir examples/sample-game completes a real MCP handshake — 132 tools + server.info returns the sample content; FUSE works, --appimage-extract-and-run documented as fallback; dist:win needs a Windows host); tests +4 → 997 → 1001 total green (new test/electron-builder-config.test.ts: yml validity + packaged-layout glob contract vs a fresh build + main-process runtime-imports-inside-the-production-dependency-closure contract)**). Distribution is now desktop-first (browsers/WS/static-site kept as dev/debug aids, no longer extended). Full detail in docs/decisions.md D40 + docs/editor.md.

Milestone Plan

  1. Docs + interface freeze — done (decisions/architecture/mcp/api).
  2. Walking skeleton — done (data/render/MCP/Mod chains end-to-end).
  3. Horizontal subsystems (Tier 1) — done: time, planting, inventory/items, economy, persistence + behavior v1 + mod introspection.
  4. Tier 2 — done: behavior language v2 (state machines + 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.
  5. Tier 3 — quests/events, stamina/skills, minigames are now content-round complete (D29) (quests/events via behavior v2 state machines — the mechanism; stamina/skills as seeded fields + behavior language + Mod; the fishing minigame as the Mod escape-hatch demo). Animation state machines + smooth movement (D38) — done: an 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).
  6. Packaging (D30/D32) — done: 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).
  7. Editor (D40) — done (E1 project layer built 2026-08-13; E2 map editing built 2026-08-13; E3 forms built 2026-08-13; E4 scene placement built 2026-08-13; **E5 embedded playtest built (2026-08-13 — D40 #13: a Play button in the editor top bar → save every dirty model (each editor mount registers an onFlush flush collector) → main compiles the project via playtest:start IPC (compileBundle(<root>/content, <root>/mods) from @nengine/pack, stateless — returns {bundle}/{error}) → the renderer boots an isolated createEngine({bundle}) + Pixi viewport + connectLocal with arrow-key move / click interact / HUD time-inventory (dev-app flow), Exit destroys engine+viewport+listeners and returns to edit mode; bundle-mode-in-renderer via node shims (the dev-app-proven src/shim/ aliases for node:fs/node:fs/promises/node:path/node:url, scoped to non-test builds — the full @nengine/core barrel now imports into the renderer, emitted chunk verified node:fs-free; bundle mode does zero fs calls D30/D36); pure orchestratable layer playtest-session.ts (saveAllFlushes + phase state machine + keydownInput) + playtest-hud.ts node-tested; fixture-proven playtest-visible correctness — a compiled fixture project (content+scene+mod) boots in bundle mode seeding the scene's building/npc/animal via seedScenes and fires a Mod hook on engine.tick(480); playable v1 = move+interact+HUD; E5 (playtesting) BUILT): a desktop-first graphical creation tool — a single Electron app, editor + runner in one (RPG-Maker style): visual map editing, scene entity placement, schema-driven content forms (all kinds), embedded playtesting, embedded MCP server for AI-assisted design (shared engine instance + shared authoring layer), packaging via the nengine-pack pipeline. New scene content kind (initial entity list, seeded at engine start) — built (D40.9): schema + seedScenes instantiation + scene.* MCP namespace live; the editor's scene placement vertical slice writes content/scenes/<id>.yaml through the shared authoring layer. E6 embedded MCP built (2026-08-13 — D40 #5/#16: the editor embeds an in-process MCP gateway serving the editor's own engine instance — streamable HTTP primary (persistent server, stable URL shown in the top bar with a copy button, per-project attach/detach) + --mcp-stdio headless secondary (pure-Node entry node apps/editor/dist/main/mcp-stdio.js --dir <project>, or electron . --mcp-stdio --dir <project> — the editor binary as a drop-in stdio MCP server); engine lifecycle in main (create on open, stop on switch/close/quit); dual-channel same world — AI MCP authoring writes disk + registry (shared authoring layer) and GUI saves re-register via syncContentWrite, full exposure no permission narrowing; mcp:status IPC + top-bar URL; 14 new node tests, 948 total green); E7 package dialog built (2026-08-13 — D40 #8: Package button → dialog pre-filled from project.yaml → pack:run IPC → runEditorPack → packGame in main (single blocking call, busy state) → result + installer hints; merge is the pure pack-options.ts shared by renderer + main; 13 new node tests, 961 total green); F1 Mod management built (2026-08-13 — D41 #2: Mods panel with per-Mod enable/disable toggles + engine enabledMods filter; project.yaml mods.enabled list (absent = all enabled); consumers (edit engine via mods:reload stop+recreate, playtest compileBundle, packaging packGame) honor the same list; MCP surface unchanged; +21 tests → 982 total green); F2 Asset management built (2026-08-13 — D41 #3: Assets panel — image thumbnails + audio rows, Import via main-side dialog (sanitized/deduped ids, never overwrites), confirm-guarded Delete; assets:list/assets:import/assets:delete IPC + main-side refreshEditEngineAssets registry refresh; form reference dropdowns include asset ids (src + map.tiles record values); +15 tests → 997 total green); **F4 editor distribution built (2026-08-13 — D41 #4/#6: the editor's own electron-builder config + dist/dist:win/build:dist scripts → real Linux AppImage dist-app/nEngine Editor-0.1.0.AppImage (workspace deps auto-collected following pnpm symlinks; sandboxed preload bundled to one CJS file; launch smoke + packaged --mcp-stdio handshake verified); +4 tests → 1001 total green)**. Implemented as a new apps/editor package in vertical slices (project layer → map editing → form generator → scene placement → playtesting → embedded MCP → package dialog → Mod management → Asset management → editor distribution). Full detail in docs/decisions.md D41 + docs/editor.md. Editor (D40) milestone COMPLETE.