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)
main.ts # Electron main: BrowserWindow + local HTTP server for the built renderer + all ipcMain.handle (incl. E7 `pack:run` calling `runEditorPack`) + 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 engine lifecycle: createEditEngine(root) (createEngine({contentDir, modsDir?})), contentKindForPath, syncContentWrite (GUI-written file → engine registry, dual-channel)
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 (CJS, sandboxed — Electron requires CJS preloads with sandbox on)
project.ts # pure: openProject/inspectProject, resolveWithin (traversal guard), readYaml/writeYaml, listContent, stats
project-yaml.ts # pure: projectYamlSchema (zod strict) + readProjectYaml/writeProjectYaml
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 main-side step: runEditorPack(root, options, hasMods) → packGame from @nengine/pack → {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 + 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
map-model.ts # pure E2 map model: createMapModel/paint/erase/setSpawn/toggleWalkable/addTileId/serialize/tileColor/collectTileIds (+ isWalkableTile fallback)
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
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, 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-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)
playtest.ts # thin E5 DOM wiring: Playtest screen — createEngine({bundle}) + createViewport + connectLocal + keyboard/click input + HUD, exit → teardown
nengine.d.ts # declare global Window.nengine typed from EditorProjectApi + EditorPlaytestApi + EditorMcpApi + EditorPackApi
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 (+ 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), and pack:run (E7: runEditorPack — packGame from @nengine/pack in-process, stateless). 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). Also hosts the --mcp-stdio headless branch. |
Preload (src/preload.ts) |
contextBridge.exposeInMainWorld("nengine", { project, playtest, mcp }) — 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). 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, and a per-kind + (new) button; 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, 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. |
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. |
project:stats |
root |
{ stats } |
{ kinds: Record<kind, count>, total, mods, assets }. |
playtest:start |
root |
{ bundle: ContentBundle } \| { error: string } |
E5. Main runs compileBundle(<root>/content, <root>/mods) from @nengine/pack (mods passed only when the dir exists) 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. 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, 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. |
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) — 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
Validated by projectYamlSchema (zod, strict — unknown keys rejected).
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. |
markSaved(model) |
Clears the dirty flag (unsaved-changes indicator on the Save button). |
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.The editor canvas is a palette-color approximation — it does NOT render the map's real image assets (map tiles → assets are playtest territory, D31). No map resize in v1; undo/redo, copy/paste, and asset-accurate rendering are documented future refinements.
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. |
markSaved(model) |
Clears the dirty flag. |
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), 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) — NO asset rendering in v1 (asset-accurate rendering is playtest territory, E5). An empty scene is valid (entities: []).
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).v1 playability: move + interact + HUD time/inventory (the D35 panels — seed picker/shop/dialogue — are not ported; reserved). 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 (22 / 124 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…). 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).
| 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). The Mods enable/disable panel remains future work. |
BUILT ✅ |
Playwright _electron E2E (mirroring D32) is reserved for a later/final round — Electron needs a display, so E1 has no runtime E2E.