D40 — the engine gains a graphical creation tool: a desktop-first (no more browser targets, D7/D8 overturned) single Electron application that is editor AND runner in one (RPG-Maker style), implemented as the apps/editor package in vertical slices. Design decisions (D40) are the source of truth for why; this document is the how — package structure, process responsibilities, the IPC contract, and the round roadmap.
packages/mcp/src/authoring.ts — validateDef/writeContentFile/updateContentFile) so AI-via-MCP and human-via-GUI produce byte-identical content. This surfaced map writing (map.* validate/create/update).content/ + mods/ conventions is a project (examples/sample-game works out of the box). project.yaml is packaging metadata ONLY (name/icon/pack flags) — NOT a project definition.nengine-pack with fields prefilled from project.yaml.scene content kind (content/scenes/<id>.yaml) — initial entity lists seeded at engine start (D40.9, built).createEngine (reads the latest contentDir) → take over rendering + input → exit destroys the instance. State isolation; the embedded MCP server stays bound to the EDIT instance.mods/ entries with an enable/disable toggle (whether a Mod participates in playtesting/packaging is content policy). No code editing — "open in external editor" suffices.apps/editor package: NOT a dev-app takeover (dev-app stays the game runner/pack source). Main process: project file IO (Node fs) + embedded engine + embedded MCP server; renderer: the editor UI.Key rejected candidates: CLI-only authoring, native rewrite, two separate Electron apps, dev-app takeover, editor-over-MCP (dogfooding), mandatory project file, hand-written per-kind forms, in-editor packaging reimplementation, scene entities folded into the map schema, playtest as a mode toggle of the shared instance, a full Mod IDE, IDE-ification (multi-tabs/rich layout), MCP permission narrowing, deleting the WS/static-site/browser capabilities.
apps/editor/
package.json # @nengine/editor; deps @nengine/core + @nengine/mcp (E6 embedded gateway) + @nengine/pack (main compile) + @nengine/render (playtest viewport) + yaml + zod; devDeps @modelcontextprotocol/sdk (E6, main-process-only) + electron + typescript + vite + vitest + @types/node
tsconfig.main.json # ESM emit → dist/main (main process + pure logic modules, incl. the mcp-stdio entry)
tsconfig.preload.json # CJS emit → dist/preload (sandboxed preload + ipc runtime consts)
tsconfig.renderer.json # type-check only (noEmit) — vite emits dist/renderer
vite.config.ts # renderer build (root = apps/editor, outDir dist/renderer) + vitest (test/**)
index.html # renderer shell (vanilla DOM + inline CSS, apps/dev precedent)
src/
ipc.ts # shared channel names + strictly typed payload types (main/preload/renderer all import; incl. F1 `mods:list`/`mods:reload` + F2 `assets:list`/`assets:import`/`assets:delete`)
main.ts # Electron main: BrowserWindow + local HTTP server for the built renderer + all ipcMain.handle (incl. E7 `pack:run` calling `runEditorPack`, F1 `mods:list`/`mods:reload`, F2 `assets:list`/`assets:import` [native file dialog when no sourcePaths]/`assets:delete` + post-write `refreshEditEngineAssets`) + E6 editing-engine/MCP lifecycle + --mcp-stdio headless branch
mcp-server.ts # pure E6 MCP wiring: createGateway/createNengineMcpServer (apps/mcp-server parity), createEditorMcpHttpServer (persistent streamable HTTP server, attach/detach per project), runStdioMcp, resolveEditorMcpDirs
edit-engine.ts # pure E6/F1 engine lifecycle: createEditEngine(root, enabledMods?) (createEngine({contentDir, modsDir?, enabledMods?})), contentKindForPath, syncContentWrite (GUI-written file → engine registry, dual-channel)
assets.ts # pure F2 main-side asset ops: listAssets (png/ogg scan + image dataUrl), importAssets (sanitize + -2 dedupe, never overwrites), deleteAsset, readAssetEntries (D31-parity table), refreshEditEngineAssets (rebuild the edit engine's registry.assets after import/delete — main-side re-read; the D31 watch is not wired into the engine)
mcp-stdio.ts # pure-node headless entry: `node dist/main/mcp-stdio.js --dir <project>` — the editor binary as a drop-in stdio MCP server
preload.ts # contextBridge → window.nengine.project + window.nengine.playtest + window.nengine.mcp + window.nengine.pack + window.nengine.mods + window.nengine.assets (CJS, sandboxed — Electron requires CJS preloads with sandbox on)
project.ts # pure: openProject/inspectProject, resolveWithin (traversal guard), readYaml/writeYaml, listContent, stats, isSupportedAssetName (.png/.ogg filter)
project-yaml.ts # pure: projectYamlSchema (zod strict, incl. F1 `mods.enabled`) + readProjectYaml/writeProjectYaml + readProjectEnabledMods (tolerant enabled-list reader)
pack-options.ts # pure E7 option merge: dialogDefaultsFromProject / projectYamlFromPack / resolvePackRequest / packHints (shared by renderer dialog + main pack step; no node imports)
pack.ts # pure E7/F1 main-side step: runEditorPack(root, options, hasMods) → packGame from @nengine/pack with the project's enabledMods → {ok, outDir, stats} | {ok:false, error}
recent.ts # pure: addRecent (cap 10, dedupe by root), loadRecent/saveRecent (tolerant of missing/corrupt files)
template.ts # pure: newProject(name, dir) — generates the minimal playable template and boot-validates via createEngine
shim/
fs.ts / fs-promises.ts / path.ts / url.ts # E5 renderer node:* shims (dev-app-proven): bundle mode does ZERO fs calls at runtime, so the stubs reject/no-op
renderer/
main.ts # editor UI shell: home screen (recent/open/new) → editor screen (top bar incl. E6 MCP URL + Copy button + E7 Package button, content sidebar incl. F1 Mods entry + F2 Assets entry + editor panel); Play button + playtest screen switch
pack-dialog.ts # thin E7 DOM wiring: Package dialog overlay (fields pre-filled from project.yaml, busy state, result/error + hints), save-back to project.yaml on run
mods-model.ts # pure F1 toggle logic: isModEnabled/enabledModsCount/toggleEnabled (null list = all enabled; first disable makes the rest explicit; empty stays [] = all disabled)
mods-panel.ts # thin F1 DOM wiring: Mods panel (per-mod id/version/hooks/load-state + Enable/Disable), toggle → writeProjectYaml + mods:reload + refresh
assets-model.ts # pure F2 panel model: imageAssets/audioAssets/mergeImported/removeById/formatAssetSize
assets-panel.ts # thin F2 DOM wiring: Assets panel (image thumbnails + audio rows, Import… via main-side dialog, confirm-guarded Delete), empty state
asset-images.ts # pure E9 tile-asset infra: assetDataUrls (image assets → {id: dataUrl}) + TileImageCache (async Image loading, error → permanent color fallback) + drawTile (asset-or-color, shared by map + scene editors)
map-model.ts # pure E2 map model: createMapModel/paint/erase/setSpawn/toggleWalkable/addTileId/serialize/tileColor/collectTileIds (+ isWalkableTile fallback) + E9 inverse-command undo/redo stacks (dirty = history non-empty, save = checkpoint) + resizeMap (pad/truncate + spawn clamp) + tileAssetId
map-editor.ts # thin E2 DOM wiring: toolbar (paint/erase/spawn/walkable + Save), palette, 32px canvas grid, mouse handlers; registers an E5 onFlush collector
scene-model.ts # pure E4 scene model: createSceneModel + building/npc/animal CRUD (footprint-aware overlap), entityAt/serialize/colors + E9 inverse-command undo/redo stacks + syncMapSize (scene bounds follow map resize) + sceneFitsIn (resize guard)
scene-editor.ts # thin E4 DOM wiring: shared map+scene canvas, Edit Scene/Edit Map mode toggle, entity palette + list, Save; registers an E5 onFlush collector (scene + map)
form-schema.ts # pure E3 derivation: zod schema → FormSchema (field types/enums/optional/refs/unions/records), reference heuristic (F2: `ASSET_RECORD_VALUE_FIELDS` — map `tiles` record values are asset refs), humanizeId
form-value.ts # pure E3 value model: initialValue/applyValue/coerceForValidate (lossless overlay + roundtrip), union detection, getPath/setPath
form-panel.ts # thin E3 DOM wiring: recursive form renderer (inputs/selects/checkboxes/refs/objects/arrays/unions/records) + validate → stringify → Save/Create via IPC; registers an E5 onFlush collector
playtest-panels.ts # thin E8 DOM wiring: the four @nengine/ui panels + seed picker row as overlays over the viewport (refresh/dispose), panel events → intents (choose {index} contract), NPC-tile talk detection helpers, inventory use direct-call, stopPropagation guard
playtest-session.ts # pure E5 orchestration: PlaytestFlush/saveAllFlushes/createPlaytestSession (save-all → compile → boot → playing state machine) + keydownInput
playtest-hud.ts # pure E5 HUD helpers: playerStatsFromSnapshot/inventorySummary (apps/dev ui.ts parity) + E8 seedListFromSnapshot/shopRowsFromDef/dialogueChoicesFromNode/buildingShopAt/dialogueActiveInWorld (the rest of ui.ts parity)
playtest.ts # thin E5/E8 DOM wiring: Playtest screen — createEngine({bundle}) + createViewport + connectLocal + keyboard/click input (NPC-tile clicks send talk, E8) + HUD + playtest-panels mount/refresh/dispose, exit → teardown
nengine.d.ts # declare global Window.nengine typed from EditorProjectApi + EditorPlaytestApi + EditorMcpApi + EditorPackApi + EditorModsApi + EditorAssetsApi
test/ # vitest (node env, mkdtemp tmp dirs): project/recent/project-yaml/template/map-model/form-schema/form-value/scene-model/playtest-session/playtest-hud/playtest-boot/mcp-server/mcp-stdio/pack-options/pack/mods-model/mods-consumers/assets/assets-model/form-ref-assets (+ fixtures/farm.yaml, scene-farm.yaml, buildings/)
Build: pnpm build = tsc -p tsconfig.main.json && tsc -p tsconfig.preload.json && tsc -p tsconfig.renderer.json && vite build. Run: pnpm dev (builds then electron .). @nengine/core types resolve through the workspace symlink to the built dist; vite aliases it to packages/core/src/index.ts for test/dev speed. E1 imported core types only (the alias never entered the bundle); E2 (map editing) needs runtime mapSchema/UNWALKABLE_TILES — the full barrel is NOT browser-safe (core's loader/mods import node:fs), so E2–E4 import it from two editor-only synthetic facets — @nengine/core-schema and @nengine/core-systems — which vite aliases (and tsconfig.renderer.json mirrors via paths) to core's pure source modules (schema/index.ts, systems/index.ts; no node builtins), keeping the renderer bundle browser-clean. E5 (playtest) brings the FULL @nengine/core barrel + @nengine/render into the renderer and neutralizes the barrel's node:fs/node:path/node:url imports with the dev-app-proven in-memory shim aliases (src/shim/ — vite.config.ts aliases the four node: builtins to stubs that reject/no-op; bundle mode performs ZERO fs calls at runtime, D30/D36, so the stubs are never invoked). The shim aliases are scoped to non-test builds (vitest runs in Node and needs the real builtins for mkdtemp/writeFile tmp fixtures). The emitted renderer chunk is verified node:fs-free by grepping the build output.
| Process | Role |
|---|---|
Main (src/main.ts) |
Owns ALL fs. Creates the 960×640 BrowserWindow (contextIsolation: true, nodeIntegration: false, sandboxed preload), serves dist/renderer/ over a local node:http server (127.0.0.1, ephemeral port, GET-only, MIME map, D32 path-traversal guard, exact files only) and loads http://127.0.0.1:<port>/index.html (the D32-proven pattern — file:// module loading is unreliable; NENGINE_EDITOR_RENDERER_URL overrides with a dev URL). Registers the 11 project:* IPC handlers (E6 adds project:close — stops the editing engine), playtest:start (E5: compileBundle from @nengine/pack; the playtest engine is booted in the RENDERER), mcp:status (E6), pack:run (E7: runEditorPack — packGame from @nengine/pack in-process, stateless), mods:list/mods:reload (F1), and assets:list/assets:import/assets:delete (F2: listAssets/importAssets/deleteAsset from src/assets.ts; assets:import opens a native multi-select file dialog when no sourcePaths are passed; a successful import/delete calls refreshEditEngineAssets — a main-side re-read of content/assets/ into registry.assets, since the engine never wires the D31 ContentLoader.watch). E6: owns the editing engine instance (createEditEngine(root) — createEngine({contentDir, modsDir?}) full fs mode on the open project; created on open/new, stopped on switch/close/quit) and the embedded MCP HTTP server (persistent on 127.0.0.1, ephemeral port, /mcp, attach/detach per project). F1: the engine is (re)created with the project's enabledMods filter (from project.yaml mods.enabled via readProjectEnabledMods) — a Mod toggle writes project.yaml then calls mods:reload, which stops + recreates the instance with the new filter (the filter is a createEngine option, so the existing reload path cannot change it); playtest:start/pack:run pass the same list to compileBundle/packGame. mods:list merges the engine's live ModStatus[] with inspectModEntry manifest reads so disabled mods still show id/version/hooks. Also hosts the --mcp-stdio headless branch. |
Preload (src/preload.ts) |
contextBridge.exposeInMainWorld("nengine", { project, playtest, mcp, pack, mods, assets }) — typed wrappers over the IPC channels. Sandboxed → must be CJS (tsconfig.preload.json, module: CommonJS; the sandboxed preload ignores the package's "type": "module"). |
Renderer (src/renderer/) |
Vanilla TS + DOM editor UI. No Node, no engine — everything through window.nengine.project (and window.nengine.playtest.start for E5, window.nengine.mcp.status for E6, window.nengine.pack.run for E7, window.nengine.mods.list/reload for F1, window.nengine.assets.list/import/delete for F2). Home screen (recent list + Open/New buttons) → editor screen (top bar: name/path/E6 MCP URL + Copy MCP URL button/E7 Package button/Play/close; content sidebar: 18 kinds with counts, file lists, a per-kind + (new) button, a non-kind Mods entry with count → F1 Mods panel, and a non-kind Assets entry with count → F2 Assets panel; status bar: totals). Clicking a map file opens the E2 map editor (mountMapEditor over the pure map-model); clicking a scene file opens the E4 scene placement editor (mountSceneEditor over the pure scene-model, with the project's first map rendered beneath — Edit Scene/Edit Map mode toggle on one shared canvas); clicking any other kind's file opens the E3 schema-driven form (mountFormPanel over deriveFormSchema + form-value); the + button opens a blank form for a new file (create flow; scenes open a blank placement editor instead). Play (E5) switches to the playtest screen, saves every dirty open model, boots an isolated createEngine({bundle}) + Pixi viewport + the E8 D35 panels (dialogue/shop/inventory/HudBar/seed picker), and exits back to edit state. Package (E7) opens the package dialog (openPackDialog over the pure pack-options merge) and runs the nengine-pack pipeline in main. Mods (F1) opens mountModsPanel — per-mod Enable/Disable toggles persisted to project.yaml. Assets (F2) opens mountAssetsPanel — image thumbnails + audio rows, import (main-side dialog), delete. |
window.nengine.project, via ipcMain.handle)The IPC surface is the extension seam for later rounds — add channels, don't restructure. All payload types live in src/ipc.ts (imported by main + preload + renderer).
| Channel | Args | Returns | Notes |
|---|---|---|---|
project:open |
{ dir?: string } |
{ project: EditorProjectInfo \| null } |
With dir: validate that folder (openProject). Without: dialog.showOpenDialog (openDirectory) → validate. null on cancel. On success, main persists to recent. |
project:new |
— | { project: EditorProjectInfo \| null } |
dialog.showOpenDialog (openDirectory + createDirectory) → newProject(folderName, dir) (template + boot validation) → recent. |
project:listRecent |
— | { entries: EditorRecentEntry[] } |
{root, name} from the userData JSON, newest first, ≤ 10. |
project:addRecent |
{ root, name } |
{ entries } |
Dedupes by root, caps at 10. |
project:close |
— | — | E6. Stops the editing engine (and detaches the embedded MCP server) when the GUI leaves a project (Close button). |
project:readYaml |
root, path |
{ content: string } |
Path-scoped: resolveWithin(root, path) guard + .yaml/.yml only. |
project:writeYaml |
{ root, path, content } |
— | Traversal guard; creates parent dirs; .yaml/.yml only. E6: when a content path is written, main also re-registers the file into the editing engine's registry (syncContentWrite) so the MCP surface sees the GUI's save (dual-channel). |
project:readProjectYaml |
root |
{ data: EditorProjectYaml \| null } |
null when absent; throws on malformed. |
project:writeProjectYaml |
{ root, data } |
— | Validates against the zod schema before writing. |
project:listContent |
root |
{ listing } |
{ kinds: [{kind, count, files: [{id, name, path}]}], mods: string[], assets: string[] } — scans content/ per contentDirNames mirroring the ContentLoader's dir-vs-single-file convention. F2: assets is filtered to the engine's supported binary extensions (.png/.ogg — isSupportedAssetName) so the form asset dropdown + sidebar/status counts match the engine's asset table. |
project:stats |
root |
{ stats } |
{ kinds: Record<kind, count>, total, mods, assets }. |
playtest:start |
root |
{ bundle: ContentBundle } \| { error: string } |
E5/F1. Main runs compileBundle(<root>/content, <root>/mods, false, enabledMods) from @nengine/pack (mods passed only when the dir exists; enabledMods = project.yaml mods.enabled so only enabled Mods land in the playtest bundle) and returns the compiled inline bundle to the renderer, or a clear per-def error when content fails compilation. Stateless — no engine instance held in main; the renderer boots createEngine({bundle}) itself. |
mcp:status |
— | { running: boolean, url: string } |
E6. running = a project's editing engine is attached to the embedded MCP server; url = http://127.0.0.1:<port>/mcp (stable for the whole session — the port is bound once at app start, the served engine swaps per project). |
pack:run |
{ root, options: EditorPackOptions } |
{ ok: true, outDir, stats } \| { ok: false, error } |
E7/F1. Main runs the nengine-pack pipeline in-process: runEditorPack(root, options, hasMods) merges the dialog options + project.yaml into a PackArgs (content <root>/content, mods <root>/mods when the dialog asks AND the dir exists, enabledMods = project.yaml mods.enabled so only enabled Mods package, outDir default <root>/dist-pack, installer implies desktop) and calls packGame from @nengine/pack. Returns the output dir + stats ({defCount, kinds, assetCount, modCount, desktop, installer, desktopDir?}) or a clear error message — content compile failures (nengine-pack: content validation failed for <dir> with per-def lines), vite build failures, and desktop/installer generation errors all surface as {ok:false, error}. Stateless + blocking — packGame exposes no progress callbacks, so the call is single and the dialog shows a busy indicator while it runs. |
mods:list |
root |
{ mods: { enabled: string[] \| null, mods: EditorModInfo[] } } |
F1. enabled = project.yaml mods.enabled (null when absent = all enabled); one EditorModInfo ({id, version, hooks, loaded, errors?, unreadable?}) per mods/ dir — loaded mods take the engine's live ModStatus, disabled/unloaded mods come from inspectModEntry (manifest + hook names without binding), unreadable entries show the dir name + unreadable: true. |
mods:reload |
— | { ok: boolean, error? } |
F1. Re-reads project.yaml for the open project and stops + recreates the editing engine with the fresh enabledMods filter (a createEngine option — the existing reload path cannot change it); the embedded MCP server re-attaches to the new instance. {ok:false, error} when no project is open. |
assets:list |
root |
{ assets: EditorAssetInfo[] } |
F2. Fresh scan of <root>/content/assets/ → {id, kind: image\|audio, mime, size, dataUrl?} sorted by id. dataUrl (data:<mime>;base64,…) is present for images only (renderer thumbnails — kept light; audio entries carry size alone, no playback in v1). Non-.png/.ogg files are ignored (they never reach the engine's asset table). |
assets:import |
{ root, sourcePaths?: string[] } |
{ imported: EditorAssetInfo[], errors?: {path, message}[] } |
F2. sourcePaths is an explicit file-path list (tests / scripted imports); absent/empty → main opens a native multi-select file dialog (openFile + multiSelections, png/ogg filter). Each file is copied into content/assets/ with a sanitized lowercased id (whitespace → -, non [a-z0-9._-] stripped) and deduped -2/-3 suffix on basename collision — never overwrites (data safety). Unsupported extensions are rejected per-file while the valid files still import (errors present only when a file failed). A successful import refreshes the editing engine's asset table (refreshEditEngineAssets). |
assets:delete |
{ root, id } |
{ ok: boolean, error? } |
F2. Unlinks the matching file in content/assets/ (id = filename minus extension; only supported extensions are reachable). {ok:false, error: "asset not found: <id>"} for an unknown id. A successful delete refreshes the editing engine's asset table. |
EditorProjectInfo: { root, name, hasContent, hasMods, projectYaml, projectYamlError? } — name = project.yaml.name or the folder basename; a malformed project.yaml does not block opening (surfaced via projectYamlError).
# Packaging metadata ONLY (D40.6) + F1 Mod enablement — NOT a project definition. Absent → editor infers from the folder name.
name: Pinefield # display name override (optional)
icon: icon.png # packaging icon path (optional)
pack: # mirrors nengine-pack flags (optional)
name: Pinefield Valley # --name (product name)
installer: true # --installer
mods: true # --mods <modsDir>
splitAssets: true # --split-assets
mods: # F1 Mod enablement (optional — absent = all enabled)
enabled: [fishing-mod] # explicit list of enabled Mod ids; present once ANY toggle happens (empty = all disabled)
Validated by projectYamlSchema (zod, strict — unknown keys rejected). The mods block is written only on an explicit toggle in the Mods panel — never on mere open — and round-trips losslessly through readProjectYaml/writeProjectYaml.
src/template.ts)newProject(name, dir): sanitizes the name into ids ("My Farm" → "my-farm", empty/unsanitizable → "farm"), writes the tree below, then boot-validates by createEngine({contentDir}) start/stop — a broken template fails loudly (a template bug is a bug).
content/time.yaml # 1 season (spring), 14 days, 240 ticks/day
content/weather.yaml # single-season table: sunny 70 / rain 30
content/interact.yaml # default order: harvest, water, sow, till
content/maps/<id>.yaml # 12×12 grass; 3×3 water pond (top-left); 4×2 tilled bed; spawn at (8,8); walkable: water false
content/items/hoe.yaml # tool
content/items/<id>-seed.yaml # seed
content/items/<id>.yaml # the harvested crop (material)
content/crops/<id>.yaml # 3 stages (2/2/1 days, water required through grown), seasons: [spring], drops 1× <id>
content/scenes/<id>.yaml # entities: [] (valid empty scene)
mods/ # empty (optional)
Minimal but playable: till → sow the seed → water → grow → harvest; the map/spawn/walkability are valid; the scene demonstrates the D40.9 mechanism with zero entities.
Click a map file under the map kind in the content sidebar → the renderer loads content/maps/<id>.yaml via project:readYaml, parses it (yaml parse), and createMapModel validates it with mapSchema.safeParse (invalid map → status-bar error, no editor). The editor panel becomes a 32px/tile canvas (plain HTML5 canvas 2D — no pixi in the editor renderer; dependency-light by design) with a palette column and a toolbar.
src/renderer/map-model.ts, pure + node-tested)The strict-typed MapModel owns a deep-cloned working copy of the def — grid ops never mutate the def handed to createMapModel. Operations:
| Function | Semantics |
|---|---|
createMapModel(def) |
mapSchema.safeParse gate → MapModel \| null (corrupt defs rejected at load). Ground layer = the layer named ground, else the first layer (terrainLayer convention). |
paint(model, x, y, tileId) |
Writes ground.data[y][x]; out-of-bounds no-op; registers new ids in the palette. |
erase(model, x, y) |
Writes DEFAULT_ERASE_TILE ("grass") when the map uses it, else the first palette id, else "" (void). |
setSpawn(model, x, y) |
Non-integer coordinates rejected; out-of-range clamped into the map. |
toggleWalkable(model, tileId) |
false → key deleted (restores the D33 fallback); true/absent → set false. The record only ever carries false entries. Caveat (documented): a fallback-blocked id (e.g. water, in UNWALKABLE_TILES) toggled off the record stays non-walkable at runtime — making it walkable needs walkable: { water: true } in YAML (editor v1 doesn't expose true-setting). |
addTileId(model, tileId) |
Registers a palette id that may not be on the map yet; no def change, no dirty flag. |
serialize(model) |
Fresh plain MapDef re-validated by mapSchema.safeParse; null when invalid — invalid edits never serialize. |
collectTileIds(model) |
Unique ids from all layers, row-major first-seen order (empty string excluded). |
tileColor(tileId) |
grass/tilled/water keep the render package's TILE_COLORS values; every other id gets a stable hash-derived hue (same id → same color). |
isWalkableTile(model, tileId) |
Mirrors the engine's D33 resolution: declared walkable value wins, else the UNWALKABLE_TILES fallback. |
undo(model) / redo(model) / canUndo / canRedo |
E9 inverse-command stacks: tile/spawn/walkable ops record before/after, resize records a full-grid snapshot. No-ops never enter the stack; a new op clears the redo stack; dirty IS history.length > 0. The palette is additive UI and does not roll back. |
resizeMap(model, w, h) |
E9 map resize: width/height + every layer (pad with the erase tile / truncate). The spawn is clamped into the new bounds (the schema does not reject an out-of-range spawn, and the engine seeds the player there). Non-square maps supported (schema allows independent width/height); invalid/unchanged dimensions return false. Undoable as a snapshot command. |
tileAssetId(model, tileId) |
The map tiles record value (tile id → asset id, D31). Copied from @nengine/render's tiles.ts rather than imported — the render barrel pulls pixi into the editor renderer, which is deliberately pixi-free (E9). |
markSaved(model) |
Clears the dirty flag AND both history stacks — save = checkpoint (no undo across saves). |
src/renderer/map-editor.ts, thin DOM wiring)Save * while the model is dirty (disabled when clean).(blocked) suffix for non-walkable ids), an add-tile-id input (addTileId), all rendered from the pure model.tileColor, 1px grid, the spawn point drawn as a distinct yellow ring/dot, and walkable-false tiles hatched in red when the Walkable tool is active.serialize → stringify (byte-identical to the MCP shared authoring layer) → project:writeYaml → markSaved → status-bar feedback; a null serialize (invalid map) shows an error and writes nothing.E9 — the canvas renders REAL assets when available: map.tiles ids with a mapped asset (dataUrls from the existing assets:list IPC, images only, loaded via an async Image cache in asset-images.ts) draw as 32px images; unmapped/loading/failed ids fall back to the palette color — the viewport's asset-then-color logic (packages/render) mirrored on the 2D canvas. Palette swatches show the same images. Undo/Redo toolbar buttons + Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y (input fields excluded; buttons disabled by stack state); Save is a checkpoint that clears the stacks. Resize inputs (width × height + Apply) — pads with the erase tile / truncates, the spawn is clamped, undoable as a snapshot command. Copy/paste remains a documented future refinement.
Click a scene file under the scene kind (or the scene kind's + (new) button for a fresh empty scene) → the renderer loads content/scenes/<id>.yaml via project:readYaml, parses it, and createSceneModel validates it with sceneSchema.safeParse. The editor panel becomes the same 32px/tile canvas the map editor uses with a mode toggle — Edit Scene (entity placement, default) and Edit Map (the E2 paint/erase/spawn/walkable tools, so both files stay reachable on one shared canvas, each with its own dirty state). The map file rendered beneath is the first map in the project (listContent order); the scene's building/animals palettes come from the project's content (building/animal kinds, footprints read from each building def via project:readYaml).
src/renderer/scene-model.ts, pure + node-tested)The strict-typed SceneModel owns a deep-cloned working copy of the def plus the map dimensions and the building footprint table — every placement decision is footprint-accurate. Operations return { ok: true } | { ok: false, reason } so invalid placements surface in the status bar:
| Function | Semantics |
|---|---|
createSceneModel(input, {mapWidth, mapHeight, footprints, defaultId}) |
sceneSchema.safeParse gate → SceneModel \| null; undefined input → a new empty scene ({id: "main", entities: []}, defaultId overrides). Footprints deep-cloned. |
addBuilding(model, buildingId, x, y) |
Unknown building id rejected (no footprint table entry — the engine fails boot on it); non-integer coords rejected; coords clamped footprint-aware into [0, map - footprint]; duplicate building-at-coords and any overlap with another entity's footprint rejected. |
addNpc(model, npcId, x, y, {dialogue?, label?}) |
Duplicate npc id rejected (the engine's seed-time duplicate check fails boot); empty dialogue/label omitted (schema min(1)). |
addAnimal(model, animalId, x, y) |
No id uniqueness (runtime ids are auto-numbered animal:<id>:<n>); clamped + overlap-checked like everything else. |
removeEntity(model, index) / moveEntity(model, index, x, y) |
Out-of-range index rejected; a move re-clamps and re-validates overlap against every other entity — a rejected move leaves the working copy untouched. |
serialize(model) |
Fresh plain SceneDef re-validated by sceneSchema.safeParse; null when invalid — invalid scenes never write. |
entityAt / entityIndexAt |
Footprint-contains point lookup (topmost wins for pre-existing overlapping defs). |
entityFootprint(model, entity) |
Buildings footprint-aware; npc/animal 1×1; an unknown building already in a loaded scene falls back to 1×1. |
entityColor(entity) |
Building = stable hash-derived hue, npc = amber, animal = red. |
undo(model) / redo(model) / canUndo / canRedo |
E9 inverse-command stacks: add/remove/move ops snapshot the entity (deep copy — later live mutations never corrupt undo state). Rejected ops ({ok:false}) and no-ops never enter the stack; a new op clears the redo stack; dirty IS history.length > 0. |
syncMapSize(model, w, h) |
E9 after a map resize: updates mapWidth/mapHeight and clamps every entity into the new bounds; a building whose footprint cannot fit is left in place (defensive — the UI runs sceneFitsIn first). |
sceneFitsIn(model, w, h) |
E9 per-building resize guard: rejects (with an entity-describing reason) when any placed building's footprint exceeds the size. |
sceneFitsAfterResize(model, w, h) |
E9 the full resize guard (bug-fix round): sceneFitsIn PLUS a simulation of syncMapSize's clamping — a shrink that would clamp two entities into overlap (the engine's seed-time placement check would fail boot) is rejected with an entity-describing reason. The scene editor runs THIS before accepting a resize. |
markSaved(model) |
Clears the dirty flag AND both history stacks — save = checkpoint (no undo across saves). |
Overlap rule: every placement and move is checked against all entities' footprints (buildings footprint-aware, npc/animal 1×1). This is deliberately stricter than seedScenes (which only fails on building-building footprint overlap and duplicate npc ids) — scenes authored here always boot.
src/renderer/scene-editor.ts, thin DOM wiring)Edit Scene / Edit Map mode toggle (one shared canvas + camera + TILE_SIZE), Undo/Redo buttons (routed by the active mode, E9), Resize inputs (E9), and a Save button showing Save * while either model is dirty.w×h footprint badge, footprint tooltip), animals, and an NPC form (free-string npc id + optional dialogue/label). Selecting an entry arms placement; canvas clicks place it (clamped), with status feedback on the actual placed coords or the rejection reason (overlap/duplicate/unknown/out-of-bounds).building shed/npc mayor/animal chicken, coords) with a confirm-guarded delete; clicking selects (highlighted on canvas + list).serialize → stringify (byte-identical to the MCP shared authoring layer) → project:writeYaml for the scene file and, when the map was edited, the map file — each dirty model written separately, markSaved, status feedback. A null serialize (invalid) shows an error and writes nothing.Entities render as plain colored shapes (footprint rects with a hash hue, amber/red circles) — entity sprites stay v1-later (asset-accurate rendering is playtest territory, E5); map TILES render real assets in Edit Map mode (E9, shared with the map editor). An empty scene is valid (entities: []).
E9 additions: the toolbar gains Undo/Redo buttons + Ctrl+Z/Ctrl+Shift+Z/Ctrl+Y, routed by the active mode (Edit Scene → the scene model's stack, Edit Map → the map model's stack), and Resize inputs (width × height + Apply): the resize runs sceneFitsAfterResize first (an uncontainable building OR a clamp-induced overlap rejects the resize — no lost entities, no boot-failing scenes), then resizeMap + syncMapSize (scene bounds follow the new map size), and clears both undo stacks (a coarse cross-model op is not undoable — the status bar says so; the dirty flag survives, so the resize itself is always savable).
D40 decision #13, implemented as bundle-mode in the renderer via shims (the chosen architecture over ws-broadcast — no network hop, dev-app-proven). Play = save-all → compile in main → boot a fresh isolated engine in the renderer → take over input → exit destroys the instance and returns to edit mode.
renderer main.ts --Play button--> playtest.ts --pure session--> saveAllFlushes()
│ (flush collector from the open editor)
├─ window.nengine.playtest.start(root) [IPC playtest:start]
│ └─ main: compileBundle(content, mods) → ContentBundle JSON
├─ createEngine({bundle}) (FULL core barrel — node:* shims neutralize fs)
├─ createViewport(gameEl, {map}) + connectLocal(engine, viewport, {sound})
└─ input: arrow keys → IntentSystem.move, click → tileAt + interact, HUD
@nengine/core barrel in the renderer — enabled by the dev-app-proven shim aliases (vite.config.ts: node:fs/node:fs/promises/node:path/node:url → src/shim/ stubs that reject/no-op; bundle mode performs ZERO fs calls at runtime, so the stubs are never invoked). The shim aliases are scoped to non-test builds — vitest runs in Node and needs the real builtins.playtest:start returns { bundle } (inline assets, D30) or { error } with a clear per-def message for content that fails compilation (nengine-pack: content validation failed for <dir>). Main is stateless — the playtest engine lives entirely in the renderer.createEngine({bundle}); Exit destroys the viewport + local-sync handle + input listeners + engine.stop(). The editor's own state (open models, dirty flags, flush collectors) is untouched — playtest edits never leak back; unsaved edits are flushed BEFORE boot so the playtest reflects exactly what will ship.seedScenes), a form-edited map change lands in the compiled bundle, and a project mods/ Mod loads (bundle.mods via compileBundle) with a hook firing on engine.tick(480).src/renderer/playtest-session.ts, node-tested)| Piece | Semantics |
|---|---|
PlaytestFlush |
{ path, content } (stringified YAML to persist) | { path, error } (a model that cannot serialize — invalid edits never write). |
saveAllFlushes(flushes, write) |
Persists every dirty model; a rejecting write or an un-serializable model is recorded as a failure (never thrown) — a single bad file cannot half-save. |
createPlaytestSession(deps) |
The lifecycle state machine: saving → compiling → booting → playing, with stop() → stopping → idle. Every I/O side (collectFlushes/write/compile/boot/teardown) is an injected dep, so the whole sequence is node-testable. A save/compile/boot failure returns { ok: false, error } and returns to idle (teardown not needed — nothing was booted). |
keydownInput(key) |
Arrow keys → raw "key:Arrow*" inputs for the local-sync mapping (dev-app keys.ts parity). |
Every editor mount (map-editor, scene-editor, form-panel) accepts an onFlush option and registers a flush collector with the renderer shell (renderer/main.ts holds the current collector; disposeCurrentEditor clears it). The collector returns the dirty models as PlaytestFlush[]:
serialize(model) when dirty (null → { error: "map invalid" });coerceForValidate + contentSchemas[kind].safeParse + resolveTargetPath (create flow included; validation failure / duplicate id / occupied single-file kind → { error }).Play calls saveAllFlushes(currentCollector(), writeYaml) BEFORE compiling — this is the whole point of "see what ships": a form-edited crop or a scene-placed building is on disk, so the compiled bundle contains it.
src/renderer/playtest.ts, thin DOM wiring)createEngine({bundle}) → engine.start() → first map def → createViewport(gameEl, {map}) → connectLocal(engine, viewport, {sound}).localSync.input("key:Arrow*") (IntentSystem move); click → viewport.tileAt + localSync.intent("interact", {tile}); pointerdown primes Web Audio.playtest-hud.ts, dev-app ui.ts parity): time via localSync.setTimeLabel (Day X, season), stats via playerStatsFromSnapshot (gold · stamina · inventory), refreshed on every player-intent.localSync.destroy() + remove listeners + viewport.destroy() + engine.stop() → screen returns to edit mode → sidebar re-renders (saved files may have changed).The four packages/ui panels + a seed-picker row mount as absolutely-positioned overlays over #playtest-game (src/renderer/playtest-panels.ts — thin DOM glue, self-injected styles, teardown backstop via replaceChildren() + explicit dispose()):
engine.world.snapshot(), registry.list<ItemDef>/ShopDef) → pure helpers (playtest-hud.ts, dev ui.ts parity) → panel update(). Every player-intent re-renders everything.choose → intent("choose", { index }) ({index} contract — the engine's IntentSystem reads args["index"]; the dev-app's {choice} key is a latent bug, NOT ported); shop buy/sell → intent with the active shop id; seed buttons → intent("select-seed", { seedItemId }); inventory use → direct InventorySystem.use (the playtest engine lives in this process — dev's "Use via MCP only" stub is moot), errors surface in the status bar.talk (the editor's local talk path the dev-app lacks); any other tile sends interact. An NPC tile never reaches interact — documented.buildingShopAt on the intent tile); dialogue visibility tracks the dialogue:active sentinel entity; the HudBar shows the REAL time system state (TimeSystem.get(), dev hardcodes day 1/spring); I toggles the inventory overlay.stopPropagation-guarded — they never re-trigger the game's click-to-interact handler. dispose() unmounts every panel before the engine stops.No split-assets support in v1 (bundles are inline; assets render via engine.assets data URLs). A failed save/compile/boot surfaces the error in the editor status bar and returns to edit mode.
D40 decision #5, implemented per decision #16 (full exposure, no permission narrowing): the editor embeds an in-process MCP gateway serving the editor's own engine instance — the same instance the shared authoring layer operates on — so external AI tools (Claude/Cursor-style agents) connect, see the current project, and author/debug it. Dual transport per decision #7.
apps/editor/src/mcp-server.ts (pure, node-tested)
createGateway(engine) # @nengine/mcp namespaces registered (23 / 132 tools / 36 resources / 17 prompts)
createNengineMcpServer(engine) # McpServer (SDK) wrapping the gateway — apps/mcp-server parity
createEditorMcpHttpServer() # persistent streamable HTTP server: 127.0.0.1, ephemeral port, /mcp
# GET (SSE) + POST + DELETE + OPTIONS (CORS preflight) per the MCP spec
# attach(engine) / detach() swap the served engine per project
runStdioMcp(dirs) # headless stdio runner for --mcp-stdio
resolveEditorMcpDirs(argv, cwd) # --dir <project> (content=<dir>/content, mods=<dir>/mods when present)
apps/editor/src/edit-engine.ts (pure, node-tested)
createEditEngine(root) # createEngine({contentDir, modsDir?}) full fs mode — the editor's own instance
syncContentWrite(engine, root, rel)# GUI-written content file → parse → schema-validate → registry re-register
apps/editor/src/mcp-stdio.ts (thin entry — no electron import)
node dist/main/mcp-stdio.js --dir <project> # the editor binary as a drop-in stdio MCP server
main.ts # engine lifecycle (open → create/start, switch/close → stop) + mcp:status IPC
createEngine({contentDir, modsDir}) full mode + the shared authoring layer's write paths). Created on project:open/project:new, stopped on project:close, project switch, and app quit. If the content fails to boot (broken project), the project still opens in the GUI and mcp:status reports running: false — a broken project costs MCP, never the GUI./mcp), so the URL is stable for the whole session; attach(engine)/detach() swap the served engine (fresh gateway + streamable-HTTP transport) on project open/switch/close. The HTTP endpoint is the primary channel for external AI tools.--mcp-stdio is a secondary headless channel for CLI/scripts: the editor binary launches as a drop-in stdio MCP server mirroring apps/mcp-server but with the editor's wiring. Two ways to invoke: node apps/editor/dist/main/mcp-stdio.js --dir <project> (pure Node, no Electron needed — this is what the tests spawn) or electron . --mcp-stdio --dir <project> (no BrowserWindow). The stdio runner loads its OWN fresh engine for the --dir project (it is a standalone server, not the GUI session's instance).item.create, map.update, …) goes through the shared authoring layer — writes content/…/<id>.yaml AND registers live in the engine registry; the GUI reads content from disk (project:listContent/readYaml), so AI-created content appears immediately.project:writeYaml) write YAML to disk as before AND main re-registers the written file into the editing engine's registry via syncContentWrite (parse → contentSchemas[kind].safeParse → registry.register). Invalid content leaves the registry untouched (the last valid def stays visible). project.yaml and non-content paths are ignored.server.disable_subsystem) can destabilize the MCP surface — not the GUI.http://127.0.0.1:43127/mcp); press Copy MCP URL.item.list, map.get, resources), author (item.create…), and debug the live edit instance (time.advance, crop.till, npc.spawn, player.intent, craft.do_craft…). Changes reflect in the GUI on the next sidebar/status refresh; GUI saves reflect in MCP immediately.| Channel | Endpoint / invocation | When | Notes |
|---|---|---|---|
| Streamable HTTP (primary) | http://127.0.0.1:<port>/mcp (GET SSE + POST + DELETE + OPTIONS) |
Always on while the app runs; serves the current project's engine when a project is open, else 503 | Persistent server, stable URL; per-project transport swap |
| stdio (secondary, headless) | node apps/editor/dist/main/mcp-stdio.js --dir <project> or electron . --mcp-stdio --dir <project> |
Standalone CLI/script channel | Own fresh engine for the --dir project; mirrors apps/mcp-server |
Engine instance lifecycle: project:open/project:new → createEditEngine(root) + start() + mcpHttp.attach(engine); project:close / opening a different project / app quit → mcpHttp.detach() + engine.stop().
D40 decision #8 — reuse the nengine-pack pipeline: the Package button in the editor top bar opens a small DOM dialog that runs packGame (from @nengine/pack) in the main process on the open project, closing the creator loop: new → edit → playtest → package a distributable desktop app. No packaging logic is reimplemented in the editor — the dialog is an option merge over the verified pipeline.
| Field | Maps to | Pre-fill (D40.6 project.yaml) |
|---|---|---|
| Product name | pack.name (nengine-pack --name) |
pack.name → top-level name → project display name |
| Icon path | top-level icon |
project.yaml.icon — stored for future use only; packGame/generateDesktop v1 ship the default Electron icon (D32.6) and this round does not change pack logic |
| Desktop app | desktop (nengine-pack --desktop) |
pack.installer === true (installer implies desktop; desktop alone is not persisted) |
| Installer config | installer (nengine-pack --installer) |
pack.installer; checking it auto-checks Desktop |
| Include Mods | mods (nengine-pack --mods <root>/mods) |
pack.mods; the mods/ dir is only included when it exists |
| Split assets | splitAssets (nengine-pack --split-assets) |
pack.splitAssets |
| Output directory | outDir (nengine-pack --out) |
<root>/dist-pack (the DEFAULT_OUT_DIR convention) |
Running the dialog saves the field values back into project.yaml first (projectYamlFromPack — persistent fields only: pack.{name, installer, mods, splitAssets} + icon; desktop/outDir are transient run options; the prev display name and other fields survive), so the next open pre-fills from the last run.
src/pack-options.ts, node-tested)| Function | Semantics |
|---|---|
dialogDefaultsFromProject(projectYaml, root, fallbackName) |
The pre-fill (EditorPackOptions) — pack.name → name → fallback chain, icon, booleans from pack.*, outDir <root>/dist-pack. |
projectYamlFromPack(options, prev) |
The save-back — writes pack.{name, installer, mods, splitAssets} + icon, deletes empty name/icon, keeps desktop/outDir out, preserves prev fields. |
resolvePackRequest(root, options, hasMods) |
The effective pack request — contentDir/outDir, `installer |
packHints(stats) |
Post-run hint lines (site serving; npm install && npm start for desktop; npm install && npm run dist for installers). |
The module imports no node builtins (outDir is ${root}/dist-pack string concat — main normalizes with resolve), so the renderer dialog and the main pack step share one merge, and the whole merge is node-testable.
src/pack.ts, node-tested)runEditorPack(root, options, hasMods) builds the PackArgs from resolvePackRequest and calls packGame (the library function — not the CLI, mirroring E5's compileBundle-in-main choice). Returns {ok: true, outDir, stats: {defCount, kinds, assetCount, modCount, desktop, installer, desktopDir?}} or {ok: false, error}. How failures surface (all through the same {ok:false, error}):
nengine-pack: content validation failed for <dir> + per-def lines;electron-builder invocation at pack time — installer mode only writes electron-builder.yml, so a missing electron-builder surfaces later as npm run dist failing inside desktop/, documented in the result hints).Progress: packGame exposes no callbacks, so pack:run is a single blocking call — the dialog's Run button switches to a disabled "Packaging…" state while main builds (typically a few seconds) and the status bar shows "packaging…".
src/renderer/pack-dialog.ts, thin DOM)An overlay opened from the top bar Package button (only when a project is open; the current project.yaml is re-read fresh for the pre-fill). Run persists the pre-fill, then calls window.nengine.pack.run({root, options}); on success the result area shows Package complete → <outDir> + the hint lines (the installer hint: "run npm install && npm run dist in the desktop folder for installers"); on failure it shows the error. Close hides the overlay.
<outDir>/ mirrors the nengine-pack output exactly (the pipeline is unchanged): index.html + assets/ + bundle.json (the self-contained static site, D30), nengine-assets/ when split (D37), and — with desktop/installer — desktop/ (Electron shell main.cjs + package.json + README-desktop.md; installer mode adds electron-builder.yml + a site copy, D32/D36).
D41 decision #2 — display + lightweight enable/disable: the content sidebar's non-kind Mods entry (count = mods/ dirs) opens a panel listing every mods/ entry with an Enable/Disable toggle. No code editing — "open in external editor" suffices. Whether a Mod participates in playtesting/packaging is content policy made concrete by the mods.enabled list — every consumer honors the same list, zero semantic divergence.
mods:list / mods:reload, main-owned)mods:list returns { enabled: string[] | null, mods: EditorModInfo[] } — enabled = project.yaml mods.enabled (null when absent = all enabled); per mod, the engine's live ModStatus when loaded (id/version/hooks/loaded/errors), else inspectModEntry (manifest + hook names WITHOUT binding — the F1 core export), else the dir name + unreadable.project:writeProjectYaml with the new { mods: { enabled } } (merged over the existing project.yaml — lossless), then mods:reload: main re-reads project.yaml and stops + recreates the editing engine with the new filter (the embedded MCP server re-attaches). The filter is a createEngine option, so the existing reloadMods path cannot change it — stop+recreate is the documented approach.src/renderer/mods-model.ts, node-tested)| Function | Semantics |
|---|---|
isModEnabled(list, id) |
true when enabled is null (absent = all enabled) or the id is listed. |
enabledModsCount(list) |
Enabled-mod count for the sidebar/status. |
toggleEnabled(list, id, on) |
The next mods.enabled list. A null list becomes explicit on the first disable (every known mod minus the disabled one); re-enabling appends; disabling the last mod yields [] (all disabled) — never a field deletion, which would mean all enabled again. |
| Consumer | How the list is applied |
|---|---|
| Edit engine (embedded MCP instance) | createEditEngine(root, enabledMods) → createEngine({contentDir, modsDir, enabledMods}); recreated on mods:reload |
playtest:start |
compileBundle(contentDir, modsDir, false, enabledMods) — only enabled Mods land in the playtest bundle |
pack:run |
packGame({..., enabledMods}) when the dialog includes Mods — only enabled Mods package |
| Engine (any consumer, incl. bundle mode) | createEngine({enabledMods}) — ModLoader.load/reload skips unlisted mods (never bound, never in engine.mods) |
Documented deviation: the --mcp-stdio headless channel (electron . --mcp-stdio --dir <project>) keeps raw-dir semantics — its fresh engine loads ALL mods/ entries, mirroring apps/mcp-server (which also reads no project.yaml). It is a script/CLI surface, not the interactive project context; the panel's list governs the editor's own engine, playtest, and packaging.
MCP surface unchanged — mod.* stays introspection-only (D10); the panel is the GUI channel.
D41 decision #3 — assets are NOT a content kind (D31: no forms, no assets.* MCP namespace, no schema registration), so this is editor-UI territory: the content sidebar's non-kind Assets entry (count = listing.assets.length, now filtered to .png/.ogg) opens a panel showing content/assets/ as image thumbnails + audio rows with import and delete. The D31 architecture stands unchanged — this round touches only apps/editor (zero engine/core changes).
assets:list / assets:import / assets:delete, main-owned)assets:list is a fresh scan of <root>/content/assets/ (pure listAssets in src/assets.ts): {id, kind: image|audio, mime, size, dataUrl?} — dataUrl for images only (renderer thumbnails; kept light), size for both (audio has no playback in v1).assets:import takes {root, sourcePaths?}. A renderer-supplied path list (tests / scripted imports) OR — absent/empty — a native multi-select file dialog in main (main owns all fs; the renderer never touches the filesystem). Each file is copied (never moved) into content/assets/ with a sanitized lowercased id (whitespace → -, non [a-z0-9._-] stripped) and deduped -2/-3 suffixes on basename collision — never overwrites (data safety; a re-import of the same file becomes grass-1-2.png). Unsupported extensions are rejected per-file while valid files still import ({imported, errors?} — errors present only when a file failed).assets:delete unlinks the matching file by id (id = filename minus extension); unknown id → {ok:false, error: "asset not found: <id>"}.The D31 ContentLoader.watch() (which re-scans content/assets/ into registry.assets on change — verified in packages/core/test/assets.test.ts) is a mechanism the engine never wires (asset hot-reload is exercised only in core tests; engine.start() does not install the watcher). So after a successful import/delete the editor calls refreshEditEngineAssets(engine, root) (src/assets.ts) — a main-side re-read (readAssetEntries, D31-parity: id = filename minus extension, base64 {kind, mime, data}, unreadable files skipped best-effort) that rebuilds the editing engine's registry.assets (a public mutable Map). The embedded MCP surface sees the refreshed table immediately; no new core API.
src/renderer/assets-panel.ts, thin DOM over a pure model)assets-model.ts, node-tested): imageAssets/audioAssets split, mergeImported (add + replace + sort by id), removeById, formatAssetSize.window.nengine.assets.import({root}) (main dialog) → status lists the imported ids; per-file errors surface in the status bar.<img src=dataUrl>, pixelated) + audio rows (id + mime + size) under it.confirm guard (content referencing a deleted asset falls back to shapes/colors at render time).content/assets/ is missing/empty.Asset-referencing fields already resolved to the asset ref kind (the E3 heuristic: src → asset for sprite image layers and sound; form-panel's refIdList — now exported — returns listing.assets for refKind === "asset"). F2 closes the one gap:
map.tiles record values (tile id → asset id) now resolve to an asset ref via a new ASSET_RECORD_VALUE_FIELDS table in form-schema.ts ({tiles: "asset"}). It is deliberately OUT of REF_KIND_BY_NAME because the same field name means something different in another schema: building.tiles is an array of footprint tile ids, NOT asset ids — only record values get the marker.type a asset id…); an arbitrary/unlisted id in a populated dropdown stays selectable (the (unlisted) option).The map-editor palette showing real asset tiles was a v1-later refinement (D41 #3) — built in E9 (assets render on the canvas AND in the palette swatches, with the color fallback).
D41 decision #4/#6 — the editor itself is packaged as a distributable desktop application with its own electron-builder pipeline, independent of nengine-pack (which stays the game packer; no --editor mode).
pnpm --filter @nengine/editor build:dist # build + electron-builder --linux (AppImage)
pnpm --filter @nengine/editor dist # electron-builder --linux (needs `pnpm build` first)
pnpm --filter @nengine/editor dist:win # electron-builder --win (NSIS — run on Windows)
The D32/D36-proven CA flag applies on this machine:
NODE_OPTIONS=--use-system-ca pnpm --filter @nengine/editor dist
apps/editor/electron-builder.yml)appId: com.nengine.editor, productName: "nEngine Editor", default Electron icon v1 (D32.6).directories.output: dist-app — kept separate from the build artifacts in dist/ so electron-builder's emptyOutDir never wipes the tsc/vite outputs.files: [dist/**, package.json] — the compiled main (ESM) + sandboxed preload (CJS) + vite renderer bundle; runtime node_modules (production deps) is auto-collected by electron-builder v26 following the pnpm workspace symlinks: @nengine/core/@nengine/mcp/@nengine/pack (built dist/), @modelcontextprotocol/sdk, yaml, zod, and transitively vite (via @nengine/pack — the E7 package dialog runs a real vite build).executableName: nengine-editor — AppImage rejects the @ electron-builder derives from the package name @nengine/editor.AppImage (category Development), win nsis (documented config — only Linux is buildable here, D32 precedent).@nengine/render is a devDependency: the vite renderer bundle inlines it and the main process never imports it at runtime — keeping it out of dependencies keeps pixi.js out of the packaged app.@modelcontextprotocol/sdk is a dependency (it is a runtime dependency of the embedded MCP server in main).vite.preload.config.ts (vite lib build, electron external, ipc.ts inlined); tsconfig.preload.json is noEmit type-check only (the same tsc-check + vite-bundle pattern the renderer uses). Sandbox + contextIsolation stay on.apps/editor/dist-app/)dist-app/
nEngine Editor-0.1.0.AppImage # Linux AppImage (~138 MB)
linux-unpacked/ # unpacked dir (faster smoke-testing)
builder-debug.yml # electron-builder diagnostics
# Linux AppImage (FUSE; if FUSE is unavailable use --appimage-extract-and-run)
ELECTRON_DISABLE_SANDBOX=1 DISPLAY=:1 "./apps/editor/dist-app/nEngine Editor-0.1.0.AppImage"
# headless stdio MCP (the packaged binary doubles as a drop-in MCP server, E6)
ELECTRON_DISABLE_SANDBOX=1 DISPLAY=:1 \
"./apps/editor/dist-app/nEngine Editor-0.1.0.AppImage" --mcp-stdio --dir examples/sample-game
Verified end-to-end (2026-08-13): AppImage launch → renderer + embedded-MCP loopback servers, home screen served (HTTP 200, nEngine Editor + #btn-open DOM), zero preload/bridge errors, renderer process running against the mounted asar; --mcp-stdio --dir completes a real MCP handshake (132 tools, server.info returns the project's content). dist:win requires a Windows host (NSIS).
| Round | Scope | Status |
|---|---|---|
| E1 — Project layer | Package scaffold (main + preload + renderer); project OPEN / NEW (built-in minimal playable template) / RECENT-OPEN / project.yaml read-write; project:listContent/project:stats; pure-logic tests. |
BUILT ✅ |
| E2 — Map editing | Visual map canvas: tile palette, ground-layer painting, spawn point, walkable visualization; writes map YAML via serialize → validate → stringify → project:writeYaml. |
BUILT ✅ |
| E3 — Content forms | Schema-driven universal form generator (zod → form UI) for all content kinds; writes through the shared authoring layer. | BUILT ✅ |
| E4 — Scene placement | Scene entity placement on the map canvas → content/scenes/<id>.yaml via the shared authoring layer. |
BUILT ✅ |
| E5 — Playtesting | Embedded same-process independent engine instance (Play → save → compile → spawn → take over → exit), bundle-mode-in-renderer via node shims + @nengine/pack compile in main. |
BUILT ✅ |
| E6 — Embedded MCP | In-process MCP gateway (streamable HTTP primary + --mcp-stdio headless secondary) serving the editor's own engine instance (per-project lifecycle) for AI-assisted design; full exposure, dual-channel (AI↔GUI same world). |
BUILT ✅ |
| E7 — Package dialog | nengine-pack integration (fields prefilled from project.yaml, saved back on run; pack:run IPC → packGame in main; result + installer hints). |
BUILT ✅ |
| F1 — Mod management | D41 #2: Mods panel (per-Mod Enable/Disable toggles → project.yaml mods.enabled), engine enabledMods filter (ModLoader.load/reload), @nengine/pack additive enabledMods, mods:list/mods:reload IPC, edit-engine/playtest/packaging consumers honor the same list. |
BUILT ✅ |
| F2 — Asset management | D41 #3: Assets panel (image thumbnails + audio rows, Import via main-side dialog with sanitized/deduped ids, 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, free-text fallback). |
BUILT ✅ |
| F4 — Editor distribution | D41 #4/#6: the editor's own electron-builder config + dist/dist:win/build:dist scripts → real Linux AppImage (productName "nEngine Editor", output dist-app/), workspace-dep packaging via electron-builder's pnpm support, sandboxed preload bundled to a single CJS file (Electron constraint), verified launch smoke + --mcp-stdio handshake from the packaged app. |
BUILT ✅ |
| E8 — D35 panels in playtest | D42 #1: the four packages/ui panels (dialogue/shop/inventory/HudBar) + a seed-picker row mount over the playtest viewport; NPC-tile clicks send talk; choose sends {index}; inventory use calls InventorySystem.use directly; stopPropagation-guarded panel clicks. |
BUILT ✅ |
| E9 — Map/scene editor experience | D42 #2: real-asset tile rendering (assets:list dataUrls + async Image cache + color fallback), inverse-command undo/redo (save = checkpoint), map resize (spawn clamp, scene-bounds sync + sceneFitsIn guard, non-square maps). |
BUILT ✅ |
Playwright _electron E2E (mirroring D32) is reserved for a later/final round — Electron needs a display, so E1 has no runtime E2E.