mcp.md 143 KB

MCP Interface Contract

The interface contract. MCP-first (D-op1): every interface in this engine is designed on this surface first; the engine API (api.md) is derived from it. This file is the spec — implementations must match it.

Principles

  1. One gateway, many namespaces (D9): a central MCP gateway exposes every subsystem under its own namespace (item.*, crop.*, save.*). "Every feature has MCP" is enforced by the registration layer: a subsystem cannot boot without registering a namespace.
  2. Creation-first, debug-second (D3): tools that build game content (write content/*.yaml) are the primary surface. Runtime tools are for debugging only — they introspect state and trigger events, they never serve players.
  3. JSON only (D18): every tool argument and resource is normalized JSON. YAML is the authoring surface, never the MCP surface.
  4. No Mod↔MCP bridge (D10): mod.* is introspection-only. MCP never invokes Mod-registered behavior; Mods never register MCP tools.
  5. Namespace registration contract — each subsystem implements:

    interface McpNamespace {
    name: string;                    // e.g. "item"
    tools: McpTool[];                // { name, description, inputSchema, outputSchema?, handler }
    resources?: McpResource[];       // { uri, description, read(params) }
    prompts?: McpPrompt[];           // { name, description, arguments?, build() }
    enabled: boolean;                // per-subsystem switch (D9)
    }
    

Implementation note (walking skeleton, 2026-08-12): resources use template URIs in the uri field (map://{mapId}) resolved by read(params) — not the uriTemplate field. Parameterized resources surface as templates in listResources with their braces intact.

Transport

  • stdio — dev toolchains (apps/mcp-server): agent connects, creates content.
  • streamable HTTP — runtime debug: connects into a running headless core process; live introspection, event triggering, snapshot/restore.
  • Both speak the same namespace surface; nothing is transport-specific.

apps/mcp-server entry options

The stdio entry serves any content/mods pair (not just the monorepo's own). Roots resolve CLI > env > default:

Source Flag / var Default
CLI --content-dir <path> / --mods-dir <path> / --ws-port <port> —
Env NENGINE_CONTENT_DIR / NENGINE_MODS_DIR / NENGINE_WS_PORT —
Default — content/, mods/ at the monorepo root; WS channel off

Relative CLI/env paths resolve against process.cwd(). Zero-arg invocation is unchanged. Example:

node apps/mcp-server/dist/index.js \
  --content-dir examples/sample-game/content \
  --mods-dir examples/sample-game/mods

The wiring is apps/mcp-server/src/index.ts: exported resolveDirs(argv, env, cwd) → createEngine → createNengineMcpServer; main auto-runs on import only under direct execution (import.meta.url === process.argv[1]), so tests import it without booting stdio.

WebSocket world-sync channel (render viewports)

Not an MCP namespace — a separate broadcast channel for render viewports (D8/D24: headless core authoritative, browsers are WebSocket viewports). It adds no tools/resources/prompts to the MCP surface. Started in the same process as the stdio entry via --ws-port <port> (or the NENGINE_WS_PORT env var), resolved in the same CLI > env > default chain as content/mods roots (there is no default — absent means the channel is off, zero-flag behavior unchanged):

node apps/mcp-server/dist/index.js \
  --content-dir examples/sample-game/content \
  --mods-dir examples/sample-game/mods \
  --ws-port 8787

Frames (JSON text, one per WebSocket message):

Direction Frame Meaning
server → client {"type":"snapshot","map":MapDef,"world":Entity[],"time":{day,season,tickInDay},"sprites":SpriteDef[],"sounds":SoundDef[],"shops":ShopDef[],"assetsIndex":Record<assetId,{kind,mime,size}>} Sent on connect and in reply to a sync request: the full current state — the map def (clients need it to build a viewport, since world entities alone lack it), every world entity, the current TimeSystem state (so viewport HUDs can show day/season without waiting for an event frame), the registered sprite/sound def catalogs (small bounded sets from registry.list at snapshot time — the render layer needs the draw/audio instructions to present the world; D26), the shop def catalog (D35 — the dev UI's shop panel reads buy/sell rows from it; additive), and the metadata-only assetsIndex (D37) — asset id → {kind, mime, size} (byte size). Bytes are NOT shipped per snapshot: clients request them on demand via assets-request (below)
server → client {"type":"event","event":string,"payload":unknown} Broadcast to ALL connected clients for every WorldEventBus emission (day-start, time-tick, crop-grow, npc-move, player-intent, input, sound-play, ...)
server → client {"type":"assets","assets":Record<assetId,AssetEntry{kind,mime,data(base64)}>} D37: the on-demand reply to an assets-request — the requested ids' full base64 entries (unknown ids ignored; the request is capped at 64 ids server-side, so larger requests are truncated and the remainder must be re-requested). Sent to the requesting client only (not broadcast)
client → server {"type":"sync"} Optional: request a fresh snapshot (viewports re-sync on each event frame)
client → server {"type":"assets-request","ids":string[]} D37: request the base64 bytes for the given asset ids (bounded — the coordinator batches at most 64 per request). The server replies with a {type:"assets"} frame; unknown ids are ignored gracefully. This replaced the D31 assets field on the snapshot — versioned protocol change (D37): old clients reading snapshot.assets break; the new client (connectWs) reads assetsIndex, requests what it needs, and feeds {type:"assets"} responses to the viewport/sound
client → server {"type":"intent","action":"move"\|"interact"\|"buy"\|"sell"\|"talk"\|"choose"\|"select-seed","args":{...}} Player input: parse → IntentSystem.handle(action, args) → the resulting player-intent event is broadcast as an event frame (IntentSystem emits it on the bus). Invalid input never crashes the server — a synthesized player-intent error frame is broadcast instead
client → server {"type":"input","input":string} Raw input string (D26): "key:ArrowUp" / "key:Space" / "pointer:click:<x>:<y>". The server emits an input bus event {input} broadcast as an event frame (behavior-language-matchable via on: "input"). Built-in mapping: key:ArrowUp/key:ArrowDown/key:ArrowLeft/key:ArrowRight additionally call IntentSystem.handle("move", {tile}) for the tile one step from the player's current position in that direction (player-centered). Unknown keys/prefixes are broadcast-only (no mapping). A non-string/empty input never crashes — a synthesized input error frame {input, ok:false, error:"input must be a non-empty string"} is broadcast instead

Event payloads are JSON-serialized defensively: a non-serializable payload (circular refs, functions) is skipped with a log line — the server never crashes on a bad payload. No ping/pong heartbeat in this slice. Browser clients use the native WebSocket; apps/dev connects via connectWs (packages/render/src/sync/ws.ts, same handle shape as LocalSyncHandle).

Gateway-Level Namespace (server.*)

Kind Name Purpose
tool server.info Engine version, subsystems and their enabled state, loaded content counts
tool server.enable_subsystem / server.disable_subsystem Toggle a namespace at runtime (D9) — {name} → {name, enabled} or an error result
resource server://status Same as server.info, as a resource
prompt server.orient Onboard an agent: engine capabilities, content layout, authoring workflow

Runtime semantics (D9, implemented): server.enable_subsystem {name} / server.disable_subsystem {name} flip a namespace's enabled switch on the gateway — the same per-gateway in-memory state server.info/server.status report (D9: subsystems can be enabled/disabled independently; state is not persisted and does not touch engine systems). A disabled namespace's tools/resources/prompts vanish from listTools/listResources/listPrompts and its tools reject calls with namespace "<name>" is disabled. Both tools return {name, enabled} (enabled = the new state). Errors: non-string/empty name → server.enable_subsystem: name must be a non-empty string / server.disable_subsystem: name must be a non-empty string; unknown namespace → server.enable_subsystem: unknown subsystem: <name> / server.disable_subsystem: unknown subsystem: <name> (the gateway's throw on a missing namespace is converted). Guard rail: disabling the server namespace itself is rejected (server.disable_subsystem: cannot disable the server subsystem) — that namespace hosts the re-enable tool.


item.* — Items & Tools

Creation tools (primary):

Kind Name Description
tool item.validate Validate an item definition (JSON) against the item schema; returns normalized JSON + errors
tool item.create Create an item definition — writes content/items/<id>.yaml (via YAML serialization) and hot-reloads
tool item.update Patch an existing item definition (merge + validate + write)
tool item.list List registered item ids with one-line summaries
tool item.get Fetch one item definition as normalized JSON

Debug tools (runtime only):

Kind Name Description
tool item.query_inventory Read current inventory state (entity-level)
tool item.give Give an item to the player inventory (debug)

Resources:

URI Description
item://catalog Full item catalog (normalized JSON)
item://{id} Single item definition

Prompts:

Name Purpose
item.create-prompt Guided template: agent builds a well-formed item definition

crop.* — Planting & Growth

Kind Name Description
tool crop.validate / crop.create / crop.update / crop.list / crop.get Definition lifecycle, as item.* (content/crops/)
tool crop.till Debug: till a plot ({x?, y?, plotId?} → {plotId, x, y, tilled}); idempotent — creates the plot entity if absent
tool crop.sow Debug: sow a seed on a tilled plot ({x?, y?, plotId?, seedItemId} → {plotId, x, y, cropItemId, seedItemId}); consumes 1 seed from the player inventory (emits item-use)
tool crop.harvest Debug: harvest a mature plot ({x?, y?, plotId?} → {plotId, x, y, cropItemId, drops: [{itemId, qty}]}); rolls drops into the player inventory (emits item-acquire per drop + crop-harvest), clears the crop
tool crop.stage Debug: force an entity into a growth stage (fires growth hooks)
tool crop.water Debug: water a tilled plot — sets watered: true ({x?, y?, plotId?} → {plotId, x, y, watered}); wraps PlantingSystem.water
resource crop://catalog, crop://{id} Crop definitions
prompt crop.create-prompt Guided crop creation (stages, water needs, seasons)

The full farming loop (till → sow → water → harvest) is drivable through MCP via crop.till/crop.sow/crop.water/crop.harvest — each wraps the matching PlantingSystem mechanism (debug, D3). crop.water is a set (not a toggle): it calls PlantingSystem.water(x,y), so watered is always true after a successful call — watering an already-watered plot keeps it watered, and calling it repeatedly is safe (a waterRequired stage never flips back to unwatered mid-loop). Growth advance (stage progression over days) itself is simulation (TimeSystem + GrowthSystem), not an MCP tool — MCP only forces stages for debugging (D3). crop.sow resolves cropItemId from the seed exactly as PlantingSystem.sow does (the crop def whose seedItem matches, not the seed id itself); crop.harvest requires the final stage (planting.harvest semantics). All three accept {x, y} or {plotId} (identical coordinate/plotId validation to crop.stage/crop.water); crop.sow additionally requires a non-empty seedItemId.


map.* — Maps & Terrain

Kind Name Description
tool map.list List registered map ids with one-line summaries ({maps: [{id, width, height, tilesetRef}]})
tool map.get Fetch one map definition as normalized JSON, including the player spawn entity (defaults to the first registered map when mapId omitted)
tool map.validate Validate a map definition (JSON) against the map schema; returns the normalized def
tool map.create Create a map definition — writes content/maps/<id>.yaml and registers it live (invalid defs are rejected without writing)
tool map.update Patch an existing map definition (merge + validate + rewrite content/maps/<id>.yaml + re-register)
resource map://{mapId} Map definition (tile layers as normalized JSON) plus the player spawn entity
prompt map.create-prompt Guided map creation (grid dimensions, layers, spawn, walkability)

The map lifecycle tools (map.validate/map.create/map.update, D40) mirror item.* exactly and share the engine's single authoring layer (@nengine/mcp authoring.ts) with every other authoring namespace — validate against mapSchema (no write on failure), write content/maps/<id>.yaml (YAML stringify), register live (create registers/overwrites; update removes then re-registers). Map authoring touches only the registry and the content file — the world map ENTITY is not re-seeded (same as all other kinds; the playtest instance reads fresh files). Exact shapes: map.validate/map.create/map.update → normalized MapDef JSON or error result (map.update: map not found: <id>); map.create of a duplicate id overwrites. The shared layer is the D40 foundation the GUI editor will call for every kind (AI via MCP and human via GUI produce byte-identical content).


time.* — Day & Season

Kind Name Description
tool time.get Current day, season, time-of-day tick
tool time.advance Debug: advance the clock by ticks/days/seasons; fires onDayStart/onSeasonChange hooks as it crosses boundaries — clock movement only, no systems step, no time-tick
tool time.tick Debug: run a full simulation step — advances the clock AND drives every system with a step (GrowthSystem grows crops/animals) and emits time-tick for intra-day scheduling (D23). {ticks?} defaults to 1
tool time.set Debug: jump to a specific day/season
resource time://state Current time state as resource
prompt time.configure-prompt Guide: authoring content/time.yaml (day length, season order)

Time configuration (day length, seasons) is content data (content/time.yaml); MCP reads it via time://config if needed.

The tick loop (D23): the headless core has no render loop, so simulation is driven explicitly by engine.tick(dt) (dt in ticks; 1 tick = 1 engine time unit, dayLengthTicks from the time def). time.tick is the MCP surface for that driver. Each tick advances TimeSystem (firing day-start/season-change boundary events as it crosses them), then runs every system's step(dt) in registration order (TimeSystem first — it emits the time-tick bus event {day, season, tickInDay} after advancing — then GrowthSystem, which accumulates day-fractions against dayLengthTicks and advances crops/animals on day boundaries), then the loop ends. time.advance deliberately does NOT do the systems step or emit time-tick — it is the raw clock primitive the tests and boundary-event tooling use. engine.tick vs time.advance are therefore semantically different: the first runs simulation, the second only moves time. An optional auto-tick (createEngine({ tickIntervalMs, tickDeltaTicks? })) drives tick from a node:timers interval (default tickDeltaTicks = 1 tick per interval; unref'd so it never holds the process open, cleared on engine.stop()); it is OFF by default so tests and headless MCP usage stay deterministic.


shop.* — Economy

Kind Name Description
tool shop.validate / shop.create / shop.update / shop.list / shop.get Shop definitions (content/shops/): stock, prices, buy/sell rules
tool shop.buy Debug: execute a purchase (fires onTransaction)
tool shop.sell Debug: execute a sale
resource shop://{shopId} Shop definition
prompt shop.create-prompt Guided shop creation

save.* — Persistence (mechanism only, D14)

Kind Name Description
tool save.snapshot Serialize the full world state to JSON (the engine's save format)
tool save.restore Restore from a snapshot (validate, migrate via schema-version hooks, inject)
tool save.migrate Dry-run / apply a schema-version migration to a snapshot
tool save.validate Validate a snapshot against the current world schema
resource save://state Latest snapshot as resource
prompt save.authoring-prompt Explain save format, versioning, migration authoring

Save policy — when to save, slots, autosave, UI — is creator content/Mods (D14), so it has no MCP tools.


behavior.* — Declarative Behavior Language (v1 core: conditions + triggers, D15)

Kind Name Description
tool behavior.trigger.validate / behavior.trigger.create / behavior.trigger.update / behavior.trigger.list / behavior.trigger.get Trigger definitions: when <condition> → fire <actions> (content/behaviors/)
tool behavior.fire Debug: manually fire a trigger by id
tool behavior.condition.validate Validate a condition expression alone
resource behavior://triggers All trigger definitions (normalized JSON)
prompt behavior.trigger-prompt Guided trigger authoring (condition grammar, action set)

Condition grammar (v1): comparisons (entity.state == "sprout", time.day >= 5) combined with and/or/not. Action set (v1): set-state, emit-event, give-item, play-sound, dialogue.

Behavior language v2: state machines + time triggers (D15, Tier 2 lead item)

v2 = v1 triggers + state machine syntax + time-based triggering primitives (onSchedule). State machines are the mechanism underneath NPC schedules, quests, and events (boundaries.md "temporal state → behavior language v2"). They are a separate content kind statemachine (content/statemachines/<id>.yaml) so the existing behavior.trigger.* tools stay intact; their MCP tools live under the same behavior.* namespace.

Kind Name Description
tool behavior.statemachine.validate / behavior.statemachine.create / behavior.statemachine.update / behavior.statemachine.list / behavior.statemachine.get State machine definition lifecycle (content/statemachines/), mirroring behavior.trigger.*
tool behavior.statemachine.status Debug: current state of a running machine instance (entity-backed; reports started: false before the machine has ever transitioned)
tool behavior.statemachine.transition Debug: force a machine instance into a state (fires onEnter/onExit, emits state-change regardless of declared transitions)
resource behavior://statemachines All state machine definitions (normalized JSON)
prompt behavior.statemachine-prompt Guided state machine authoring (states, transitions, schedule entries)

StateMachineDef shape (content kind statemachine):

{
  "id": "quest-id",
  "initial": "inactive",
  "states": [
    { "id": "inactive" },
    { "id": "active", "onEnter": [{ "action": "give-item", "itemId": "parsnip-seed", "qty": 1 }] },
    { "id": "complete", "onExit": [{ "action": "emit-event", "event": "quest:done" }] }
  ],
  "transitions": [
    { "from": "inactive", "to": "active", "on": "day-start", "when": { "op": "gte", "path": "time.day", "value": 3 } },
    { "from": "active", "to": "complete", "when": { "op": "gte", "path": "player.inventory.parsnip", "value": 5 } }
  ],
  "onSchedule": [
    { "at": "day-start", "to": "working" },
    { "at": { "day": 1, "season": "summer" }, "to": "resting", "when": { "op": "eq", "path": "payload.season", "value": "summer" } }
  ]
}
  • states[] — at least one state; each state has an optional onEnter/onExit action list (same 5-kind action set as triggers). onEnter runs when entering the state; onExit when leaving. State ids are unique.
  • transitions[] — from/to must reference declared states. A transition fires when on matches the current bus event name AND when (a v1 ConditionExpr over the trigger ctx) evaluates true; at least one of on/when is required. A transition with no on is a pure condition transition evaluated on every bus event (trigger-like). First matching transition in declaration order wins; at most one transition per machine per event.
  • onSchedule[] — the time primitive (v1 of v2; no tick loop yet — documented limitation). Entries fire on TimeSystem boundary events: at: "day-start" / at: "season-change" match those events; at: { day?, season? } (at least one present) matches the day-start/season-change payload. to must reference a declared state; optional when is an extra condition evaluated against the trigger ctx (including payload.day/payload.season). Schedule entries apply from any state and are evaluated after transitions (so explicit event/condition transitions take priority). A transition to the machine's current state is a no-op (no onEnter/onExit, no state-change).

Runtime semantics (v2): the behavior engine (TriggerSystem, id "trigger") drives both triggers and machines in the same match pass under the same re-entrancy guard — trigger actions and machine transitions run triggers-first, machines-second, and events emitted during the pass are never re-matched. Machine state persists on a world entity { id: "sm:<machineId>", type: "statemachine", machineId, state } — snapshot-able by save.*, inspectable by MCP. Instances are created lazily on first transition (never at boot, so existing worlds/snapshots are unaffected). Each machine transition emits a state-change bus event {machineId, from, to} mapped by the engine to the onStateChange hook (machinery for quest-tracking Mods; onTriggerFire stays for triggers). behavior.statemachine.status returns {machineId, state, started} where state is the declared initial and started: false before the instance exists. Quest progress convention (boundaries.md): player.questProgress.<questId> set via set-state actions.

Design decisions (v2, D20) — (1) separate content kind statemachine + behavior.statemachine.* tools rather than a behavior-kind discriminated union: the existing trigger tool names stay truthful, content dirs stay 1:1 per kind, and the behavior://triggers resource keeps a clean meaning; (2) onSchedule over a tick loop: the engine has no auto-driven tick (GrowthSystem.step has no driver) — schedules hook the existing day-start/season-change bus events, so no new game loop; fine-grained intra-day timing awaits a tick loop; (3) TriggerSystem extended (id kept "trigger") rather than a sibling system so triggers and machines share one match loop and one re-entrancy guard; (4) machine state on a world entity (not an in-memory map) so save.snapshot/save.restore persist it for free.


dialogue.* — Dialogue & Story (content-document system, D19)

Dialogue is an engine system (boundaries.md item 2): authored content documents with nodes, choices, and conditions — a content document, NOT a state machine (D19). AI-authored dialogue is a core creation scenario (creation-first, D3).

Kind Name Description
tool dialogue.validate / dialogue.create / dialogue.update / dialogue.list / dialogue.get Dialogue definition lifecycle (content/dialogues/<id>.yaml), mirroring item.* exactly
tool dialogue.start Debug: start a dialogue; returns the current node (text + available choices)
tool dialogue.choose Debug: pick a choice by index (into the available choices); returns the next node
resource dialogue://catalog All dialogue definitions (normalized JSON)
resource dialogue://{id} Single dialogue definition
prompt dialogue.create-prompt Guided dialogue authoring (nodes, choices, conditions, actions)

DialogueDef shape (content kind dialogue):

{
  "id": "welcome",
  "startNode": "hello",
  "nodes": [
    {
      "id": "hello",
      "text": "Welcome to your farm!",
      "choices": [
        {
          "text": "Thanks! What can I plant?",
          "to": "seed-tip",
          "when": { "op": "gte", "path": "time.day", "value": 1 },
          "actions": [{ "action": "give-item", "itemId": "parsnip-seed", "qty": 1 }]
        }
      ]
    },
    { "id": "seed-tip", "text": "Parsnip seeds grow well in spring.", "to": "farewell" },
    { "id": "farewell", "text": "See you around." }
  ]
}
  • nodes[] — at least one node; each node has a unique id, a text string, and exactly one of choices or to (or neither — a terminal node). A choices node branches; a to node is linear; a terminal node ends the dialogue when entered.
  • choices[] — each choice has text, to (a declared node), optional when (a v1 ConditionExpr evaluated against the dialogue ctx), and optional actions (the same 5-kind action set as triggers — set-state/emit-event/give-item/play-sound/dialogue; runs when the choice is picked, before moving to to).
  • actions on a node run when the node is entered.
  • startNode must be a declared node id.

Runtime semantics: one active dialogue at a time; state persists on a world entity { id: "dialogue:active", type: "dialogue", dialogueId, nodeId, payload? } — snapshot-able by save.*. dialogue.start creates it (lazily) and returns the start node; dialogue.choose picks from the available choices (those whose when evaluates true against the dialogue ctx — time.*, player.*, world.<entityId>.*, payload.*, the same path-resolution convention as triggers), runs the picked choice's actions, moves to the target node, and runs that node's actions. Entering a node with no available outbound path (a terminal node, or a choices node whose conditions all fail) ends the dialogue: the node's text is still delivered in the result, dialogue-end is emitted, and the active entity is removed. dialogue-start {dialogueId, nodeId} and dialogue-end {dialogueId} bus events map to hooks onDialogueStart/onDialogueEnd. The dialogue action kind inside dialogue nodes is a reserved no-op in this slice (nested dialogue arrives later).

Design decisions (D21): (1) state on a world entity (not a player field / in-memory map) so save.snapshot/save.restore persist it for free and MCP can inspect it like any entity — mirrors the statemachine decision (D20.4); (2) linear to nodes surface a synthesized Continue choice so the headless driver has one uniform advance action (choose(index)) — a to node's text is still presented before advancing; (3) available choices are filtered by when at read time and choose indexes into that filtered list (a gated-out choice is hidden, not erroring); (4) terminal / all-gated nodes auto-end the dialogue — otherwise a headless dialogue with no outbound path would soft-lock (there is no dialogue.end tool); (5) dialogue.start/dialogue.choose are debug tools (D3) — dialogue is authored content; the runtime drive surface is for headless testing.


weather.* — Weather (lightweight probability-roll system, D19)

Weather is a lightweight engine system (boundaries.md item 3): a probability table in content/weather.yaml (a single-file content kind, like time.yaml) keyed by season; the engine rolls a weighted pick on day-start. "Roll from a probability table" is a mechanism; the behavior language has no randomness (D19). No creation tools — the weather table is one file, so creation tools (which write per-id files) would be meaningless; the table is authored directly in YAML.

Kind Name Description
tool weather.get Debug: current weather (id, optional display name, current season)
tool weather.set Debug: force a weather id (mechanism; emits weather-change on change)
resource weather://state Current weather state

WeatherDef shape (content kind weather):

{
  "id": "default",
  "defaults": { "sunny": { "name": "Sunny" }, "rain": { "name": "Rain" } },
  "seasons": [
    { "season": "spring", "weathers": [{ "id": "sunny", "weight": 50 }, { "id": "rain", "weight": 30 }] },
    { "season": "summer", "weathers": [{ "id": "sunny", "weight": 70 }, { "id": "rain", "weight": 15 }] }
  ]
}
  • seasons[] — at least one season entry; each has a season name (matching time.yaml season names) and weathers[] (1+) of { id, weight } where weight is a positive integer (relative probability).
  • defaults — optional per-weather display metadata ({ name }); keys must be declared weather ids (schema-enforced).

Runtime semantics: weather state lives on a world entity { id: "weather", type: "weather", weatherId } created at boot with the current season's first table entry (deterministic — no RNG at boot). The first day-start rolls a weighted pick from the current season's table (the RNG is injectable for deterministic tests; default Math.random). When the current season has no table entry, the first season's table is used (fallback). weather-change {from, to} is emitted only when the weather actually changes, mapped to hook onWeatherChange. weather.get → {weatherId, name?, season}; weather.set → {weatherId, name?, season, from?} (from present when it changed). Unknown weather id → error result.

Design decisions (D21): (1) state on a world entity (snapshot-able, save.*-covered) created at boot so weather.get is always defined; (2) injectable RNG (constructor arg, default Math.random) for deterministic roll tests; (3) initial weather = first entry of the current season's table (no boot-time randomness); (4) no creation tools and no prompt — the single-file content kind makes per-id creation meaningless; (5) season is read from TimeSystem.get().season at roll time (the time def owns season names).


mod.* — Introspection Only (D10)

Kind Name Description
tool mod.list Loaded Mods: id, manifest, status, registered hooks
tool mod.status One Mod's detail: hook points bound, content overrides applied
tool mod.reload Restart the headless core with re-loaded Mods (D13: code Mods restart, they don't hot-reload)
resource mod://{id} Mod manifest + hook binding detail

No tools exist to invoke Mod behavior through MCP.

Implemented (2026-08-12): mod.* namespace is live (3 tools + 1 resource, no prompts). See the Change Log for exact result shapes and the mod.reload semantics decision.


placement.* — Grid Placement (geometry/spatial primitive, D19/D22)

Grid placement is an engine primitive (boundaries.md item 4): grid footprint + overlap validation + buildability-read-from-map-data is geometric mechanism the behavior language can't express ("geometry/spatial → engine primitive"). Building definitions — footprint, buildability rule, movement blocking — are data (content/buildings/<id>.yaml, content kind building). Buildings in the world are entities (type: "building"), so save.* snapshot/restore covers them for free.

Kind Name Description
tool placement.building.validate Validate a building definition (JSON) against the building schema; returns the normalized def
tool placement.building.create Create a building definition: validates, writes content/buildings/<id>.yaml, registers live
tool placement.building.update Patch an existing building definition (merge + validate + rewrite + re-register)
tool placement.building.list List registered building ids with one-line summaries
tool placement.building.get Fetch one building definition as normalized JSON
tool placement.place Debug: place a building entity at a grid origin (validates def, map bounds, buildability, overlap) → placed entity or error result
tool placement.remove Debug: remove the building occupying a grid cell → removed entity or error result
tool placement.list Debug: list all placed building entities
tool placement.occupies Debug: the building occupying a grid cell, or null
resource placement://buildings Building definition catalog (normalized JSON)
resource placement://{id} Single building definition (missing id → MCP error building not found: <id>)
resource placement://placed Live placed building entities
prompt placement.create-prompt Guided building definition authoring (footprint, buildable tiles, blocksMovement)

BuildingDef shape (content kind building):

{
  "id": "shed",
  "name": "Shed",
  "footprint": { "w": 2, "h": 2 },
  "buildable": "map",
  "blocksMovement": true,
  "description": "Stores tools and harvests."
}
  • footprint — the building's grid size in cells; w/h are positive integers. Placement uses the footprint as an axis-aligned rectangle anchored at the placement origin (x, y).
  • buildable — which tiles allow placement (tile ids come from the map's layers; the terrain layer is the map layer named "ground", else the first layer — see api.md §4):
    • "map" — any tile id on the map is buildable (no tile-type restriction).
    • { "tiles": ["grass", ...] } — every footprint cell must carry one of the listed tile ids (non-empty list).
    • "any" — no tile-type restriction at all (the tile check is skipped entirely; the escape hatch for non-terrain/debug structures). "map" and "any" behave identically in v1 — "map" signals the def was authored against the map's terrain and is the explicit form reserved for a future map schema with unwalkable tile flags.
  • blocksMovement — whether a placed instance blocks NPC grid movement (consumed by MovementSystem, Task B, via placement.occupies). A false building is walk-over-able by NPCs but still blocks placement overlap (buildings block buildings).
  • name / description — optional display metadata.
  • shop — optional string (D35): a registered shop def id the building is a front for. placement.place copies it onto the placed instance (building.shop), so UIs discover shops from world entities (the dev app's shop panel opens when a shop-carrying building is clicked).

Runtime semantics: placement.place {buildingId, x, y} → the placed entity {id: "building:<buildingId>:<x>:<y>", type: "building", buildingId, x, y, w, h, blocksMovement} or an error result (unknown building; footprint out of map bounds; a footprint cell on a non-buildable tile; overlap with an existing building). placement.remove {x, y} → the removed entity or an error result (no building occupies the cell). placement.list → {buildings: [placed entities...]}. placement.occupies {x, y} → the building whose footprint contains the cell, or null. Every place/remove emits a placement-change bus event {type: "place"|"remove", buildingId, x, y, w, h} → hook onPlacementChange (Mods that track "what got built where" — build-quests, achievements — get their machinery). v1 occupancy rule: buildings block buildings; the player and non-building entities are transient and do not occupy cells in this slice (NPC blocking is MovementSystem's concern, Task B).

Design decisions (D22): (1) buildable as a three-form union ("map" / {tiles} / "any") over a boolean flag — the map's tile ids ARE the terrain vocabulary, so restricting by tile id list is the natural authoring form; "map"/"any" collapse behaviorally in v1 (documented); (2) buildings as world entities (type: "building") so save.* persists them with zero system-side storage; (3) buildings-block-buildings only in v1 — the player is transient, NPC occupancy is MovementSystem's job (Task B); (4) placement-change event + onPlacementChange hook per the tri-criterion: placement outcomes are spatial (not behavior-language-expressible), build/removal Mods are plausible, and the debug tools already exist; (5) creation tools named placement.building.* — buildings are the subsystem's content kind, so lifecycle tools live under the namespace like item.*/crop.* do; (6) no placement content kind named building tool aliases — placement.building.list (defs) vs placement.list (placed instances) stay distinct.


npc.* — NPC Grid Movement (engine primitive, D19/D22)

Grid movement + blocking is an engine primitive (boundaries.md item 5): moveTo is tile-to-tile with a simple blocking check — no pathfinding (A*/BFS) and no collision system (out of scope for the engine). NPC schedules are behavior language v2 content (state machines) — this system does NOT schedule anything. NPCs are world entities {id, type: "npc", x, y, ...}; there is no new schema — the movement API is MovementSystem (system id "movement", MCP namespace npc.*).

Kind Name Description
tool npc.list Debug: all npc entities ({npcs:[{id,x,y}]})
tool npc.get Debug: one npc entity (unknown → error result npc not found: <id>)
tool npc.spawn Debug: create an npc entity at a grid cell (rejects blank id, bad coords, duplicate id)
tool npc.move Debug: moveTo — validates npc, map bounds, walkable tile, blocking; emits npc-move
tool npc.teleport Debug: no-check position set (emits npc-move; skips bounds/walkability/blocking)
tool npc.path Debug (D33): compute an A* grid path for an npc (4-neighbor, Manhattan; blocks = blocksMovement buildings + other grid entities). Pure query — does not move the npc
resource npc://{npcId} Live npc entity (missing id → MCP error npc not found: <id>)

No prompt and no creation tools: NPCs are runtime entities, not authored content — there is no content/npcs/ kind and nothing to author (a creator gives an NPC its schedule via statemachine content). npc.* is debug-only (D3), so the subsystem checklist's lifecycle/prompt clauses do not apply (mirroring save.*, which is also a content-free mechanism).

Runtime semantics: npc.spawn {id, x, y} → the created entity {id, type: "npc", x, y}. npc.move {npcId, x, y} → {npcId, from: {x, y}, to: {x, y}} after moving, or an error result: movement: unknown npc: <id> / movement: target out of bounds: (x,y) / movement: tile (x,y) is not walkable / movement: blocked by building <id> at (x,y). npc.teleport {npcId, x, y} → the same {npcId, from, to} shape but skips bounds/walkability/blocking (still requires a valid npc and non-negative coords). Both emit the npc-move bus event {npcId, from: {x,y}, to: {x,y}}.

npc.path (D33): npc.path {npcId, to: {x, y}} → {npcId, from: {x, y}, to: {x, y}, path: [{x, y}, ...]} — the A* grid path from the npc's current cell to to including start and goal (4-neighbor, Manhattan heuristic, array-based open set — maps are ~16×16, so no binary heap), or {npcId, path: null, reason: "unreachable"|"out of bounds"|"blocked"}. Walkability = the D33 map-walkability resolution (isWalkableTile: map.walkable[tileId] when declared, else the UNWALKABLE_TILES fallback); blocked = a blocksMovement building footprint (via PlacementSystem.occupies) or another grid entity (npc/player) on the cell. It is a pure query — the npc is never moved (stepwise movement along a path is documented future work). Errors: npc.path: npcId must be a non-empty string / movement: unknown npc: <id> / npc.path: to must have integer x and y. Backs MovementSystem.findPath(npcId, to) → TilePoint[] | null (the engine API).

Validation chain (moveTo): (1) target coords are non-negative integers; (2) the npc entity exists and is type: "npc"; (3) the target is within map bounds (map entity width/height); (4) the target tile is walkable — the D33 walkability resolution via the shared isWalkableTile(map, x, y) helper (tile id read from the map entity's terrain layer — the layer named "ground", else the first layer; a tileId declared in map.walkable uses its boolean, an undeclared tileId falls back to the UNWALKABLE_TILES convention — default {"water"} — see api.md §4); (5) the target cell is not blocked — a building whose blocksMovement is true occupies the cell (via PlacementSystem.occupies, resolved from the system host; other NPCs do NOT block in v1). Then npc.x/npc.y are set and npc-move is emitted. moveTo semantics are byte-identical to pre-D33 for existing content (water still blocked; a map with no walkable declaration is unchanged).

Design decisions (D22): (1) MovementSystem is a system (id "movement") rather than a free module so it can resolve PlacementSystem from the system host (auto-booted before it) and keep one consistent blocking source; (2) walkability is a tile-id convention: the map schema has no per-tile walkability flag, so the engine treats the tile id "water" as unwalkable (the walking-skeleton map's water tiles are the demonstration) — superseded in D33: the map schema now carries an optional walkable?: Record<tileId, boolean> flag, and the single source of truth is isWalkableTile (declared value when present, else the UNWALKABLE_TILES constant); (3) buildings block, NPCs do not in v1 — building occupancy is stable geometry (perfect for a blocking check), NPC-vs-NPC overlap is a simulation concern that would need a resolution policy (who yields?) — deferred; npc.path (D33) treats other grid entities as blocked cells (a pure query — pathfinding sees other npcs/player as obstacles, unlike moveTo's single-tile check, which stays v1); (4) no onNpcMove hook — the npc-move bus event is already matchable by the behavior language (on: "npc-move" in a state machine transition), so the tri-criterion's "Mod hook demand" arm fails (Mods get the machinery via content, not a hook); the event's primary consumer is the render/sync layer; (5) npc.teleport exists because headless testing/QA often needs to reposition an NPC past blockers without writing a valid path — a no-check primitive; it still emits npc-move so render sync stays consistent. D33: npc.path is an additive debug query wrapping MovementSystem.findPath (A*, 4-neighbor, Manhattan, array-based open set) — moveTo/teleport semantics are untouched (stepwise NPC movement along a path is documented future work; pathfinding is a query mechanism today).


player.* — Player Intent (engine primitive, D25)

Player input is an engine primitive (boundaries.md item 8): one IntentSystem (id "intent") turns player actions (move, interact) into world changes. Every input channel — the dev app's local sync, the WebSocket viewport channel ({"type":"intent"}), and this MCP debug tool — calls the SAME handle, so a player action behaves identically whether driven by a human clicking the canvas or an agent calling player.intent. Every handle call emits a player-intent bus event {action, tile, ok, error?, detail?} (successes AND failures — browser HUDs and WS viewports rely on it for feedback), which the behavior language can also match (on: "player-intent"). Since D29 the event ALSO maps to the onPlayerIntent hook (HookContext.payload = the intent result) — Mods are hooks-only (D6/D13), so the hook is the Mod-visible surface for the transient intent payload (see decisions.md D29.2). Hooks are not MCP surface; no tool/resource/prompt change.

Kind Name Description
tool player.intent Drive the player entity: {action: "move"\|"interact"\|"buy"\|"sell"\|"talk"\|"choose"\|"select-seed", ...args}. Success → the IntentResult; failure → error result player.intent: <issue>
resource player://state The live player entity JSON (id, type, x, y, inventory, gold, flags, selectedSeed?, ...)

Action semantics (IntentSystem.handle(action, args)):

  • move {tile:{x,y}} — validate the target (non-negative integers → within map bounds → walkable tile via the shared map-grid helper + UNWALKABLE_TILES = {"water"} → not blocked by a blocksMovement building via PlacementSystem.occupies), then set player.x/player.y. Errors: intent.move: target out of bounds: (x,y) / intent.move: tile (x,y) is not walkable / intent.move: blocked by building <id> at (x,y). Success detail: moved to (x,y). The player move reuses the shared tile/placement helpers directly — it does NOT go through MovementSystem.moveTo, so the npc-move behavior and its tests are untouched (D25).
  • interact {tile:{x,y}} — a smart context action on a plot tile. The v1 priority order is content-ized in D33: the engine reads a single-file interact def (content/interact.yaml, kind interact; no authoring tools — weather/time precedent) at call time — registry.get("interact", "default") else the first def of the kind — and iterates its order array, executing the FIRST applicable action. Applicability conditions stay engine mechanisms; only the ORDER is content policy. Without content/interact.yaml the built-in default [harvest, water, sow, till] applies (zero behavior change for existing content):
    1. harvest — plot has a crop at its final stage (detail harvested <cropId>, rolls the drop into inventory).
    2. water — plot tilled + planted + not watered (detail watered plot).
    3. sow — plot tilled + empty + player has a seed. D35: the seed comes from player.selectedSeed when that field names an owned seed-kind item (detail sowed <ItemDef.name>), else the FIRST seed-kind item in player.inventory (first key whose item def kind === "seed"; consumes 1). A successful sow keeps player.selectedSeed — clearing it is creator policy (the field is set via the select-seed action; an invalid/stale selection degrades to the first-seed fallback).
    4. till — plot absent or not tilled (creates the plot entity, detail tilled plot).

Non-applicable actions in the list are skipped; if none apply, the existing error falls through (crop <cropId> is not ready to harvest for a planted watered immature crop, else no seed in inventory).

Interact delegates to the existing PlantingSystem mechanisms (till/sow/water/harvest) — no new farming logic. Validation failures are ok:false IntentResults, never throws.

  • buy {shopId, itemId, qty?} (D35) — delegate to EconomySystem.buy (default qty 1). Success detail = the buy result {shopId, itemId, qty, total, gold}. Errors (all ok:false): player.intent: buy requires itemId (missing itemId), player.intent: buy requires shopId, player.intent: buy qty must be a positive integer, and the economy errors pass through unchanged (economy: unknown shop: <id>, economy.buy: <shop> does not sell <item>, economy.buy: insufficient gold (need N, have M)).
  • sell {shopId, itemId, qty?} (D35) — delegate to EconomySystem.sell. Success detail = the sell result {shopId, itemId, qty, total, gold}. Errors mirror buy (player.intent: sell requires itemId / ... requires shopId / ... qty must be a positive integer; economy errors pass through: economy.sell: <shop> does not buy <item>, economy.sell: insufficient <item> in inventory (have N, need M)).
  • talk {npcId?|tile?} (D35) — start an NPC's dialogue. Resolution: npcId (must be a type: "npc" entity) else tile (the npc entity ON that grid cell) else error player.intent: talk requires npcId or tile. The dialogue id comes from the NPC entity's dialogue?: string field (creator content/Mod spawns set it — the sample mayor carries dialogue: "mayor"); an NPC without the field errors player.intent: npc <id> has no dialogue. Errors: player.intent: unknown npc: <id> / player.intent: no npc at tile. Success calls DialogueSystem.start(dialogueId) and the detail = the resulting DialogueState {dialogueId, nodeId, text, choices}.
  • choose {index} (D35) — advance an active dialogue: DialogueSystem.choose(index); detail = the new node state. Errors: player.intent: choose requires a non-negative integer index (non-integer/negative), player.intent: no active dialogue (nothing active), dialogue: choice index out of range: <index> (index into the available choices; a terminal/all-gated node auto-ends the dialogue on entry — D21).
  • select-seed {seedItemId?} (D35) — set/clear the sow seed selection: a non-empty seedItemId that names a registered seed-kind item sets player.selectedSeed; absent/empty clears the field (delete). Success detail {selectedSeed: string|null}. Error: player.intent: <id> is not a seed item (unknown item or non-seed kind). The field is a plain player-entity field — snapshot-able, save.*-covered, Mod-readable.

No prompts and no creation tools: the player is a runtime entity, not authored content. player.* is a debug/input surface (D3 precedent — runtime MCP is for debugging and now for driving the same intent mechanism a human drives from the browser).

Design decisions (D25): (1) one engine mechanism, three thin channels — MCP player.intent, the WS {"type":"intent"} message, and connectLocal.intent all call IntentSystem.handle; (2) the player-intent event fires for EVERY intent including failures so HUDs/viewports always get feedback; (3) the interact priority order was the v1 hardcoded engine default, explicitly documented as content-izable later — D33 content-izes it (a single-file interact content kind carries the order; applicability stays engine mechanism); (4) player move reuses the shared map-grid/placement helpers instead of MovementSystem.moveTo, keeping npc-move behavior and tests untouched.


animal.* — Animals (GrowthSystem reuse, D19/D23)

Animals reuse GrowthSystem (boundaries.md item 6): an animal is a world entity {id: "animal:<animalId>:<n>", type: "animal", animalId, x, y, stageIndex?, daysInStage?, fed?} that advances through the same stage-progression mechanism as crops, with feedRequired replacing waterRequired on its stages. There is no new animal system — GrowthSystem step (driven by the tick loop, Task A) advances both plot and animal entities in one pass. The animal definition (growth cycle + produce table) is data (content/animals/<id>.yaml, content kind animal); feed/produce rules (what food, when, what the produce yields) are behavior-language content (D19) — the engine only emits the events the language hooks.

Kind Name Description
tool animal.validate / animal.create / animal.update / animal.list / animal.get Definition lifecycle, as item.*/crop.* (content/animals/<id>.yaml)
tool animal.spawn Debug: create an animal entity at a grid cell ({animalId, x, y} → the entity)
tool animal.feed Debug: mark an animal entity fed ({id} → {id, animalId, fed: true}) — sets the fed flag a feedRequired stage gates on
tool animal.stage Debug: force an animal entity into a growth stage (fires animal-grow)
tool animal.entities Debug: all placed animal entities ({animals:[...]})
resource animal://catalog All animal definitions (normalized JSON)
resource animal://{id} Single animal definition (missing id → MCP error animal not found: <id>)
prompt animal.create-prompt Guided animal authoring (stages, feed needs, produce table)

AnimalDef shape (content kind animal):

{
  "id": "chicken",
  "stages": [
    { "name": "chick", "days": 2, "feedRequired": false },
    { "name": "grown", "days": 2, "feedRequired": true },
    { "name": "laying", "days": 1, "feedRequired": true }
  ],
  "produces": [{ "itemId": "egg", "qty": 1, "chance": 1 }]
}
  • stages[] — at least one stage; each { name, days, feedRequired } mirrors the crop stage model with feedRequired in place of waterRequired. days positive int; feedRequired boolean.
  • produces[] — at least one { itemId, qty, chance? } (same shape as crop drops). This is the produce table (data only) — the actual granting is behavior-language content (D19). chance optional 0..1 (omitted = always).

Runtime semantics: animal.spawn {animalId, x, y} → {id: "animal:<animalId>:<n>", type: "animal", animalId, x, y, stageIndex: 0, daysInStage: 0, fed: false} (n = one past the highest existing suffix so multi-instances and post-restore spawns don't collide) or an error result (unknown animal def → growth: unknown animal def: <id>; bad coords → growth: x and y must be non-negative integers). animal.feed {id} → {id, animalId, fed: true} (unknown/non-animal entity → growth: unknown animal: <id>); feeding emits the animal-fed bus event {animalId, animalDefId} (behavior-language-matchable via on: "animal-fed"; no hook — see below). animal.stage {id, stageIndex} → {id, animalId, animalDefId, stageIndex, stage} (unknown entity / unknown def / out-of-range index → error result); delegates to GrowthSystem.forceAnimalStage, which also emits animal-grow. animal.entities → {animals:[placed entities...]}.

Growth cycle (mechanism): each in-game day (GrowthSystem's day boundary, driven by the tick loop) every animal is evaluated: if the current stage's feedRequired is true and the animal's fed is false, the day is skipped (no growth, no produce — exactly like an unwatered waterRequired crop stage). Otherwise a mature animal (final stage) emits animal-produce {animalId, animalDefId, stageIndex, stage} and its fed resets to false (feeding is consumed daily); a non-final animal increments daysInStage and on reaching stage.days advances stageIndex, resets daysInStage and fed, and emits animal-grow {animalId, animalDefId, stageIndex, stage} (the same shape as crop-grow, per stage advance including into the final stage). Both events map to hooks onAnimalGrow / onAnimalProduce. There is deliberately no separate animal-mature event and no onAnimalFed hook: animal-grow already covers reaching maturity, animal-produce covers ongoing production, and the animal-fed bus event is already behavior-language-matchable — the tri-criterion's "Mod hook demand" arm fails for feed (D22.4's reasoning applies identically).

Design decisions (D23): (1) animals are an entity kind + GrowthSystem generalization, not a new system — animals are isomorphic to crops (stages, days-per-stage, a per-day gate), so the only new mechanism is the fed flag mirroring watered (D19 verdict); (2) the animal schema holds the growth cycle + produce table ONLY — feed rules and produce/harvest rules are behavior language (D9/decision #9): the engine emits animal-grow/animal-produce/animal-fed bus events and grants nothing; (3) animal.spawn/feed/stage/entities are debug tools (D3) — animal instances are runtime entities, but the definition lifecycle is creation-first (mirroring crop.*); (4) animal.list lists definitions (consistent with item.list/crop.list); placed instances are a separate debug tool animal.entities (mirroring placement.list vs placement.building.list).


sprite.* — Procedural Sprites (presentation data, D26)

Sprites are the presentation half of the rendering presentation pipeline (D26/D31): a sprite content kind (content/sprites/<id>.yaml) carrying programmatic draw instructions — an ordered list of primitive shape layers per animation frame, plus the image layer (D31: {shape:"image", src: <assetId>, x?, y?, w?, h?} referencing a binary file in content/assets/). They are data (authored in YAML, validated by zod) and MCP-addressable like every other content kind. The image layer references assets by id — assets themselves are NOT a content kind and have NO MCP namespace (binary files are filesystem content; MCP-crafted YAML references them by id; see D31). A def with spriteRef missing on a crop stage/building/animal stage simply means the renderer falls back to the existing shape drawing — spriteRef is always optional.

Kind Name Description
tool sprite.validate Validate a sprite definition (JSON) against the sprite schema; returns the normalized def
tool sprite.create Create a sprite definition: validates, writes content/sprites/<id>.yaml, registers live
tool sprite.update Patch an existing sprite definition (merge + validate + rewrite + re-register)
tool sprite.list List registered sprite ids with one-line summaries
tool sprite.get Fetch one sprite definition as normalized JSON
resource sprite://catalog Full sprite catalog (normalized JSON)
resource sprite://{id} Single sprite definition (missing id → MCP error sprite not found: <id>)
prompt sprite.create-prompt Guided template for authoring a procedural sprite definition

SpriteDef shape (content kind sprite):

{
  "id": "parsnip-mature",
  "frames": [
    {
      "name": "idle",
      "durationTicks": 1,
      "layers": [
        { "shape": "rect", "x": 8, "y": 8, "w": 16, "h": 16, "fill": "#2e7d32" },
        { "shape": "circle", "x": 16, "y": 8, "r": 6, "fill": "#66bb6a" }
      ]
    }
  ]
}
  • id — unique kebab-case string.
  • frames[] — at least one frame; each frame is a time slice of the sprite. name (optional display label) and durationTicks (optional non-negative int, how many engine ticks the frame plays before the next; a single-frame sprite is static) describe the frame; layers is an ordered paint list (1+, drawn back-to-front).
  • layers[] — each layer is one of { shape: "rect"|"circle", x, y, ... } (per-shape sizing + required fill matching "#rrggbb") or { shape: "image", src, x?, y?, w?, h? } (D31; zod strict, shape is the union discriminator — unknown keys are rejected). rect uses w/h (positive ints); circle uses r (positive int). The image layer's src is a required non-empty asset id (a file in content/assets/), x/y position the image's top-left in tile space (default 0,0), and w/h size it in tile-space units (the crop never exceeds the source image; absent = natural pixel size). Coordinates are relative to the sprite's tile-space anchor (top-left origin; the render layer maps them to tile pixels).

Runtime semantics: sprite defs are inert data consumed by the render layer (round 2). Entities carrying a spriteRef field (player, plots, buildings, animals — set by the systems in api.md §4) reference a def by id; the renderer draws the def's current frame when the ref resolves, falling back to the existing shape drawing when it does not. There are no new systems and no hooks — sprites are a content kind + appearance field, and the snapshot frame carries the full sprites catalog so viewports never need a second request (D26).

Design decisions (D26/D31): (1) programmatic shapes over atlas assets — primitive draw instructions keep content data-driven, testable, and dependency-free; the image layer (D31) adds per-asset image references (the engine never loads an atlas format — single-tile assets by id; a sprite-sheet atlas is a documented future path); (2) spriteRef on crop stages / animal stages / building defs (and the player entity seed) is optional — a missing ref degrades to shape drawing, so existing content and tests are untouched; (3) the snapshot carries the sprite catalog so render viewports get draw instructions in-band with the world state they present.


sound.* — Synthesized Sounds (presentation data, D26)

The audio half of the presentation pipeline (D26/D31): a sound content kind (content/sounds/<id>.yaml) carrying synthesizer instructions (synth) and/or an audio asset reference (src — a file in content/assets/, D31). play-sound actions (behavior language v1, §behavior) reference sounds by id and emit a sound-play bus event {soundId} — behavior-language-matchable and broadcast as an event frame so the render layer can play the sound. Assets themselves are NOT a content kind and have NO MCP namespace — src references an asset by id; binary files are filesystem content (D31).

Kind Name Description
tool sound.validate Validate a sound definition (JSON) against the sound schema; returns the normalized def
tool sound.create Create a sound definition: validates, writes content/sounds/<id>.yaml, registers live
tool sound.update Patch an existing sound definition (merge + validate + rewrite + re-register)
tool sound.list List registered sound ids with one-line summaries
tool sound.get Fetch one sound definition as normalized JSON
resource sound://catalog Full sound catalog (normalized JSON)
resource sound://{id} Single sound definition (missing id → MCP error sound not found: <id>)
prompt sound.create-prompt Guided template for authoring a synthesized sound definition

SoundDef shape (content kind sound):

{
  "id": "coin",
  "synth": { "wave": "sine", "freq": 880, "durationMs": 120, "gain": 0.5, "slideTo": 1320 }
}
  • id — unique kebab-case string.
  • synth — the v1 synthesis spec (zod strict): wave is one of sine|square|triangle|sawtooth; freq is a positive number (Hz); durationMs a positive number; gain optional 0..1 (output level); slideTo optional positive number (end frequency — a sweep).
  • src — optional audio asset id (D31): a file in content/assets/ the render player decodes and plays (base64 → decodeAudioData); src takes precedence over synth at play time, and a src whose asset is missing or undecodable is silently skipped.
  • At least one of synth/src is required (schema .refine).

Runtime semantics: sound defs are inert data consumed by the render layer (round 2). The behavior-language play-sound {sound} action emits the sound-play bus event {soundId} (the sound id is emitted regardless of whether the def is registered — the engine's mechanism is the event, matching the audio row of boundaries.md: "engine emits events, render layer plays"). There are no new systems and no hooks; the snapshot frame carries the full sounds catalog so viewports can synthesize on demand.

Design decisions (D26/D31): (1) synth + asset-source sounds over raw audio files — a small declarative synth spec keeps audio data-driven and dependency-free; src audio assets (D31) add file playback through the same asset pipeline (a .ogg in content/assets/ referenced by id); (2) play-sound upgrades from a headless no-op to the documented audio event emitter (boundaries.md "Audio" row) — the event is the mechanism, playback is render-layer freedom.


rng.* — Deterministic Seeded RNG (engine mechanism, D28)

The engine's single randomness mechanism (D28): a deterministic seeded RNG mirroring LÖVE's love.math.RandomGenerator. createEngine({ seed?, ... }) seeds the engine's one RandomSource (engine.rng, exposed on the facade); every randomness consumer draws from it via SystemInitCtx.rng (WeatherSystem day-start rolls and PlantingSystem harvest drop chances — previously bare Math.random). Same seed → identical world randomness (replay/tests/Mods); save.* snapshots carry rngState so restore re-anchors the sequence. MCP is a debug surface (D3) for this mechanism — it reads/writes engine.rng directly, no system behind it.

Kind Name Description
tool rng.get Debug: current RNG state {seed, calls} — the seed the world's determinism is anchored to and how many draws have been made since that seed
tool rng.set Debug: reset the engine RNG to a seed (finite number required) → {seed, calls: 0}; subsequent world randomness becomes deterministic from that seed
resource rng://state Current RNG state (same shape as rng.get)

Runtime semantics: rng.get → {seed, calls} where seed is the anchor (the value most recently passed to setSeed/the engine seed) and calls counts draws since that seed. rng.set {seed} validates the seed is a finite number (non-number/NaN/Infinity → error result rng.set: seed must be a finite number), resets the sequence and the draw counter to 0, and returns the new {seed, calls: 0}. Determinism contract: every world-random draw (weather rolls on day-start, crop/animal drop chances on harvest) comes from this one source, so two engines with the same seed produce identical simulations; rng.set mid-run makes all subsequent randomness deterministic from the new seed.

Design decisions (D28): (1) one engine-owned RNG injected via SystemInitCtx rather than per-system instances — draw order is globally orderable and replayable, and rng.* has exactly one surface to report; (2) rng.get/rng.set + rng://state only, no prompts — the RNG is not authored content and offers no authoring guidance (D3's debug-surface precedent, mirroring weather.*/time.*); (3) save.* carries rngState {seed, calls} (optional, migration-free) and restore re-anchors to the stored seed with calls reset to 0 (draw replay is documented as not built — D28).


particle.* — Particle Definitions (presentation data, D34)

A particle def is a pure-visual emission profile (content kind particle, content/particles/<id>.yaml, zod strict): { id, emitRate?, lifetime?: {min,max}, speed?: {min,max}, angle?, spread?, gravity?, size?: {min,max}, colors?: ["#rrggbb",...], fade? } — all fields optional; the render layer applies documented engine defaults (emitRate 10, lifetime 500–1000 ms, speed 10–30 px/s, angle 0°, spread 360°, gravity 0 px/s², size 2–4 px, single #ffffff, fade true). Particles are inert presentation data — no engine system consumes them; content triggers a burst via the behavior-language emit-event action ({action:"emit-event", event:"particle", payload:{particleId, x?, y?, entityId?}}, payload x/y are tile coords, absent → the named entity or the player), the render layer listens for the particle bus event and spawns/animates the burst (D34).

Kind Name Description
tool particle.validate Validate a particle definition (JSON) against the particle schema → normalized def or error result
tool particle.create Validate + write content/particles/<id>.yaml + register live (overwrites on duplicate id)
tool particle.update Merge a patch over the existing def (validate + rewrite + re-register; unknown id → error particle.update: particle not found: <id>)
tool particle.list → {particles: [{id, emitRate}]} summaries (registration order)
tool particle.get → one ParticleDef JSON or error result (particle.get: particle not found: <id>; blank id → particle.get: particleId must be a non-empty string)
resource particle://catalog → {particles: [defs...]}
resource particle://{id} → one def (missing id → MCP error particle not found: <id>)
prompt particle.create-prompt Guided particle-def authoring (field defaults, emit-event trigger example)

shader.* — Built-In Filter Definitions (presentation data, D34)

A shader def selects one of the engine's BUILT-IN parameterized filters (content kind shader, content/shaders/<id>.yaml, zod strict): { id, filter: "blur"\|"tint"\|"grayscale"\|"sepia"\|"hue"\|"brightness", params?: {[k]: number\|string} } — no GLSL/shader code in content (the implementations ship with the engine; content only parameterizes them). Parameter semantics: blur {strength} (default 4), tint {color:"#rrggbb"} or {r,g,b} (0–255, default white), grayscale {factor} (0–1 desaturation, default 1), sepia {}, hue {degrees} (default 0), brightness {factor} (default 1). Shader ids are referenced from SpriteDef.filters (per-sprite) and from the weather visual mapping (whole-map, D34). Unknown shader id at use time → silently skipped by the render layer.

Kind Name Description
tool shader.validate Validate a shader definition (JSON) against the shader schema → normalized def or error result
tool shader.create Validate + write content/shaders/<id>.yaml + register live (overwrites on duplicate id)
tool shader.update Merge a patch over the existing def (validate + rewrite + re-register; unknown id → error shader.update: shader not found: <id>)
tool shader.list → {shaders: [{id, filter}]} summaries (registration order)
tool shader.get → one ShaderDef JSON or error result (shader.get: shader not found: <id>; blank id → shader.get: shaderId must be a non-empty string)
resource shader://catalog → {shaders: [defs...]}
resource shader://{id} → one def (missing id → MCP error shader not found: <id>)
prompt shader.create-prompt Guided shader-def authoring (built-in filter library, params per filter)

animation.* — Animation State Machines (presentation data, D38)

An animation def is an animation state machine (content kind animation, content/animations/<id>.yaml, zod strict): { id, initial, states: [{id, spriteRef, fps?, loop?}], transitions?: [{from, to, on?}] }. Each state maps a named state (e.g. idle, walk, dance) to a sprite def id — that sprite's internal frames give the state's animation at the state's fps (default 10, the DEFAULT_ANIMATION_FPS); loop: false plays the state once then holds its last frame. transitions is an optional authoring aid (an allowed-transition graph) — the runtime does NOT enforce hard transitions (content can jump between states freely; enforcement would break content freedom, D38). The state machine is driven by an entity's anim?: string field (a plain entity field the behavior language already sets/clears via set-state player.anim "dance" / set-state player.anim null — null is a supported clear): the render layer resolves the state as (1) the explicit anim field when it names a declared state, else (2) motion inference — "walk" while the entity is tweening between tiles / "idle" when still, else (3) initial. An entity whose spriteRef matches an animation def id uses that def; no match → today's rendering unchanged (additive, no regression).

Kind Name Description
tool animation.validate Validate an animation definition (JSON) against the animation schema → normalized def or error result (bad initial / duplicate state ids / transition to an undeclared state / strict-key violations)
tool animation.create Validate + write content/animations/<id>.yaml + register live (overwrites on duplicate id)
tool animation.update Merge a patch over the existing def (validate + rewrite + re-register; unknown id → error animation.update: animation not found: <id>)
tool animation.list → {animations: [{id, stateCount}]} summaries (registration order)
tool animation.get → one AnimationDef JSON or error result (animation.get: animation not found: <id>; blank id → animation.get: animationId must be a non-empty string)
resource animation://catalog → {animations: [defs...]}
resource animation://{id} → one def (missing id → MCP error animation not found: <id>)
prompt animation.create-prompt Guided animation-def authoring (states, spriteRef, fps/loop, state resolution, set-state null clear)

scene.* — Initial-World Scenes (seed-time instantiation, D40.9)

A scene def is an initial-entity list (content kind scene, content/scenes/<id>.yaml, zod strict) the engine instantiates at seed time — the D40.9 mechanism that makes editor scene placement persistable and playtestable (WYSIWYG). { id, entities: [{ type: "building", buildingId, x, y } | { type: "npc", npcId, x, y, dialogue?, label? } | { type: "animal", animalId, x, y }] } — a z.discriminatedUnion("type", [...]); x/y are non-negative integer tile coords on every entity. Structural validation only (references to building/animal defs are validated at seed time, not schema time — loading order makes schema-time reference checks unsafe). Instantiation semantics (seed time, after systems boot, before Mods load, in entity order): building → PlacementSystem.place(buildingId, x, y) (full footprint/bounds/buildable/overlap validation; unknown buildingId or a placement conflict FAILS boot loudly); npc → a plain npc entity {id: npcId, type: "npc", x, y, dialogue?, label?} (duplicate npc ids FAIL boot loudly — world.create silently overwrites, so the seed checks first); animal → GrowthSystem.spawnAnimal(animalId, x, y) (unknown animalId FAILS boot loudly). Scene instances use the SAME entity id formats as the runtime systems (building:<buildingId>:<x>:<y>, animal:<animalId>:<n>, npc = the npcId directly), so they are indistinguishable from runtime-placed entities — save snapshots, the WS snapshot, and viewport rendering all work for free. v1: every registered scene instantiates — multi-scene selection is a future creator-content decision.

Kind Name Description
tool scene.validate Validate a scene definition (JSON) against the scene schema → normalized def or error result (bad discriminated-union type, negative coords, strict-key violations)
tool scene.create Validate + write content/scenes/<id>.yaml + register live (overwrites on duplicate id; a NEW scene takes effect on the next engine start — instances are seeded at boot)
tool scene.update Merge a patch over the existing def (validate + rewrite + re-register; unknown id → error scene.update: scene not found: <id>)
tool scene.list → {scenes: [{id, entityCount}]} summaries (registration order)
tool scene.get → one SceneDef JSON or error result (scene.get: scene not found: <id>; blank id → scene.get: sceneId must be a non-empty string)
resource scene://catalog → {scenes: [defs...]}
resource scene://{id} → one def (missing id → MCP error scene not found: <id>)
prompt scene.create-prompt Guided scene-def authoring (entity types, id formats, seed-instantiation semantics, overlap/buildability notes)

craft.* — Crafting (runtime-only, D6/recipe content kind)

A recipe def is pure data (content kind recipe, content/recipes/<id>.yaml, zod strict): { id, output: {itemId, qty}, inputs: [{itemId, qty}], station? } — output is a single {itemId, qty} object (the produced item), inputs is a 1+ array of {itemId, qty} (the consumed ingredients), and station? is an optional string naming a required crafting building (authoring hint — the runtime does NOT enforce station presence, v1). The craft namespace is runtime-only: the three definition-lifecycle tools (validate/create/update) go through the shared authoring layer (writing content/recipes/<id>.yaml + live register, byte-identical to item.* semantics), while list/get are read-only and do_craft is the runtime entry point. No resources, no prompts (recipes are plain data exposed via list/get; the subsystem checklist's resource/prompt items are deliberately waived for this runtime-only namespace). Runtime crafting lives in CraftingSystem (packages/core/src/systems/crafting.ts): craft(recipeId) looks up the recipe, checks the player's inventory has every input qty (throws craft: insufficient <itemId> on the first shortfall), deducts all inputs, adds the output, emits the craft-success bus event {recipeId, output: {itemId, qty}}, and returns {output, consumed}. available(recipeId) is a pure check (recipe exists AND every input is in stock). Recipes are register-only content — no system holds recipe state; CraftingSystem reads them live from the registry.

Kind Name Description
tool craft.validate Validate a recipe definition (JSON) against the recipe schema → normalized def or error result (missing output/inputs, non-positive qty, strict-key violations)
tool craft.create Validate + write content/recipes/<id>.yaml + register live (overwrites on duplicate id)
tool craft.update Merge a patch over the existing def (validate + rewrite + re-register; unknown id → error recipe.update: recipe not found: <id>)
tool craft.list → {recipes: [id, ...]} (array of registered recipe id strings, registration order)
tool craft.get → one RecipeDef JSON or error result (recipe not found; blank id → recipeId required)
tool craft.do_craft Runtime: craft one recipe via CraftingSystem.craft(recipeId) — deducts inputs from the player inventory, adds the output, emits craft-success; returns {ok, result: {output, consumed}} or an error result (recipeId required / crafting system not available / craft: unknown recipe: <id> / craft: insufficient <itemId>)

Subsystem Checklist (MANDATORY)

When adding a subsystem, complete ALL of:

  • Definition lifecycle tools: validate / create / update / list / get (writing content/<kind>/<id>.yaml, hot-reloading)
  • Debug tools only where runtime control is meaningful (D3)
  • At least one resource exposing the subsystem's data as normalized JSON
  • At least one authoring prompt
  • Schema + system + namespace + hook points (plugin template, D12)
  • docs/mcp.md section AND docs/api.md mapping updated in the same change (D-op2)

Change Log

Date Change Files
2026-08-13 Documentation backfill + sample-game recipe content — NO MCP surface change (132/36/17/23 held). The prior craft.* round shipped the namespace + recipe content kind but left the docs incomplete; this entry closes every gap found by a #problems_and_diagnostics audit. §1 (mcp.md): added the missing ## craft.* namespace section (was absent — violated the D-op2 "interface change must update docs/mcp.md in the same change" hard constraint). The section documents the actual implemented behavior (not the over-claimed changelog text): 6 tools (craft.validate/create/update via the shared authoring layer + craft.list → {recipes: [ids]} + craft.get → def or recipe not found + craft.do_craft → CraftingSystem.craft), no resources, no prompts (runtime-only namespace; the checklist's resource/prompt items are deliberately waived), the craft-success bus event (NOT craft-done), and exact error strings (craft: unknown recipe: <id> / craft: insufficient <itemId>). §2 (api.md): added the missing recipe row to the §7 Schema Map with the CORRECT schema {id, output: {itemId, qty}, inputs: [{itemId, qty}], station?} (the prior changelog's outputs:[...]/category? was wrong — actual is singular output object + station?); added craft.* to the §6 createEngineNamespaces factory list (was 22, now 23); corrected the item row's kind enum (was 5 values, actual is 7: `tool seed
2026-08-13 Crafting system + festival templates + BGM audio + lifecycle/bug-fix round — MCP surface now 132 tools / 36 resources / 17 prompts / 23 namespaces. New 23rd namespace: craft.* (runtime-only, no content-kind — craft.validate/create/update/list/get/do_craft/craft.list_crafts via new content kind recipe (content/recipes/<id>.yaml, zod strict {id, inputs:[{itemId,qty}], outputs:[{itemId,qty}], category?}). Creation tools go through the shared authoring layer; runtime do_craft consumes inputs from inventory → produces outputs → emits craft-done bus event, with exact missing-qty / not-found error messages. server.orient + prompts updated to list it. Sample-game 8 starter recipes + 5 festival food recipes adopt the schema (baked-potato, parsnip-soup, jam, bread, cake, fish-stew, strawberry-smoothie, autumn-feast). Festival templates + BGM: the behavior-language play-bgm/stop-bgm actions now support optional fadeMs (type `number undefined) — sample game's four new seasonal BGM sounds (bgm-spring/bgm-summer/bgm-autumn/bgm-winter) + victory BGM auto-switch via afestival-cyclestate machine (season + day-range conditions, day-start matches switch BGM and emit festival reward items).render/src/sound.tsBGM playback fixes: removed non-existentOscillatorNode.loopand instead uses a chord-loopsetIntervalover four notes with linearRamp frequency sweeps; stop correctly clears the interval. **Engine lifecycle hardening**:WorldEventBusgainsclear(),SystemsHostgains asyncdestroy()(calls each system'sdestroy?.()with per-system try/catch), andengine.stop()now calls both — fixes event/systems resource leaks. **ContentLoader.watch reliability**: adds a 250 ms polling fallback (setInterval) that snapshots directory mtimes + asset key sets perContentKind, so environments wherefs.watchevents are unreliable (sandboxes) still detect file changes and hot-reload. **Sample-game schema enrichment**:ItemDefgainsrarity(common/uncommon/rare/epic),durability(number, optional; inventory use decrements and removes on 0),description(string, optional),effects.consumable.stamina/gold(inventoryuse()emitsitem-consumeevent; consumable gold adds toplayer.gold), andspriteRef. **Cross-platform test fixes**: Windows path handling inapps/mcp-server/test/sample-game.test.ts(absolute paths usepath.join, platform-aware assertions) andapps/editor/test/pack-options.test.ts(normalized/...paths); client-surface test updated for the new craft namespace (124→132 tools, 22→23 namespaces,craftadded to subsystem list);sample-game.test.tsgold assertion now accepts 840/990 to tolerate festival daily bonus rewards;item-namespace.test.ts/sound-namespace.test.tsfixtures carryrarity/mode; vite configs raisedtestTimeout` to 20 000 ms so pack/mods tests don't time out under cold cache. All tests green.
2026-08-13 scene content kind + scene.* MCP namespace (D40.9) — MCP surface now 124 tools / 36 resources / 17 prompts / 22 namespaces. New content kind scene (content/scenes/<id>.yaml, zod strict — {id, entities: [{type: "building", buildingId, x, y} \| {type: "npc", npcId, x, y, dialogue?, label?} \| {type: "animal", animalId, x, y}]} via z.discriminatedUnion("type"); x/y non-negative integers; structural validation only — building/animal references are validated at seed time, not schema time). New engine start step seedScenes (AFTER initSystems, BEFORE Mods load — system APIs are callable): every registered scene instantiates in entity order — building via PlacementSystem.place (full footprint/bounds/buildable/overlap validation; unknown buildingId or placement conflict FAILS boot loudly), npc as a direct entity {id: npcId, type: "npc", x, y, dialogue?, label?} (duplicate npc id FAILS boot loudly — world.create silently overwrites), animal via GrowthSystem.spawnAnimal (unknown animalId FAILS boot loudly). Scene instances use the SAME entity id formats as the runtime systems (building:<buildingId>:<x>:<y>, animal:<animalId>:<n>, npc = the npcId) — save.*, the WS snapshot, and the viewport work for free. v1: ALL registered scenes instantiate (multi-scene selection is a future creator-content decision). Namespace mirrors the authoring namespaces through the shared authoring.ts layer: scene.validate/create/update + scene.list ({scenes: [{id, entityCount}]}) + scene.get (scene not found: <id>) + resources scene://catalog/scene://{id} + prompt scene.create-prompt (entity types, id formats, seed-instantiation semantics, overlap/buildability notes). server.orient lists the new tools and the content/scenes/<id>.yaml authoring path. No scene content added to examples/sample-game/ or content/ (the sample mod spawns the mayor and players place buildings — a sample scene would collide with existing tests/play loops); scene demonstration lives in tests + docs. Tests: core scene.test.ts (13), mcp scene-namespace.test.ts (9). 758 tests green (was 736). See decisions.md D40. mcp.md, api.md, decisions.md (D40), AGENTS.md, packages/core (schema/scene.ts, schema/index.ts, engine.ts, index.ts), packages/mcp (namespaces/scene.ts, index.ts, namespaces/server.ts, test/scene-namespace.test.ts), apps/mcp-server/test/client-surface.test.ts
2026-08-13 Shared authoring layer extraction + map.* creation tools (D40 foundation) — MCP surface now 119 tools / 34 resources / 16 prompts / 21 namespaces. The validate/create/update definition-lifecycle logic duplicated in every authoring namespace (item/crop/shop/behavior trigger+statemachine/dialogue/placement building/animal/sprite/sound/particle/shader/animation) is extracted into a single shared module packages/mcp/src/authoring.ts: validateDef(kind, raw, tool) (schema safeParse → normalized def or formatted error), writeContentFile(engine, kind, def) (mkdir + write ${def.id}.yaml + live registry register, behind the requireContentDir bundle-mode guard), updateContentFile(engine, kind, id, patch, {tool, errorNoun}) (merge + validate + remove + rewrite + re-register), and authoringTools(engine, kind, opts) generating the three MCP lifecycle tools with per-kind descriptions (tool prefix item/behavior.trigger/placement.building, display noun, error noun, article, schema label). All existing authoring namespaces now call the shared module — zero behavior change (all 727 pre-existing tests green proves byte-identical semantics: identical error strings, write paths, register/overwrite/remove-re-register order). map.* gains validate/create/update (5 tools total with list/get; the D40 early ruling that "map has no creation tools" is overturned — the shared layer adds map write, and visual map editing in the GUI editor writes map YAML through the same path): identical semantics to item.* — create writes content/maps/<id>.yaml + registry.register, error result on invalid without writing, update merges, map.update: map not found: <id>, map authoring touches only registry/file (the world map ENTITY is not re-seeded). New prompt map.create-prompt (grid dimensions, layers, spawn, walkability authoring guidance). Resources unchanged (34); namespaces unchanged (21). New packages/mcp/test/map-namespace.test.ts (9 — mkdtemp lifecycle: validate valid/invalid, create write+register+duplicate overwrite, update merge+rewrite/unknown/invalid, list/get, map://{mapId}, create-prompt). 736 tests green (was 727). See decisions.md D40 (implementation note). mcp.md, api.md, decisions.md (D40), AGENTS.md, packages/mcp (authoring.ts, namespaces/{item,crop,shop,behavior,dialogue,placement,animal,sprite,sound,particle,shader,animation,map}.ts, test/map-namespace.test.ts), apps/mcp-server/test/client-surface.test.ts
2026-08-13 Engine rename to nEngine (D39) — NO MCP surface change (116 tools / 34 resources / 15 prompts / 21 namespaces held). Cosmetic identity/branding rename only: packages @farm/* → @nengine/*, MCP stdio server identity farm-mcp-server → nengine-mcp-server, server.info/server://status engine id "farm-engine" → "nengine" (shape unchanged), save/bundle format literals farm-save/farm-bundle → nengine-save/nengine-bundle (v1-era breaking change — old files not accepted; CURRENT_SAVE_VERSION/BUNDLE_VERSION stay 1), packaging CLI farm-pack → nengine-pack, Vite defines __FARM_BUNDLE_DEFAULT__/__FARM_ASSETS_BASE__ → __NENGINE_*, env vars FARM_CONTENT_DIR/FARM_MODS_DIR/FARM_WS_PORT → NENGINE_* (CLI flags unchanged), split-assets dir farm-assets → nengine-assets, appId prefix com.farm. → com.nengine.. server.orient + the creation-namespace prompts address "the nEngine". No tool/resource/prompt signatures change, no protocol changes, no system behavior changes; creator content is untouched (map id "farm", "farm-tiles", game name "Pinefield Valley"). Historical changelog entries keep their original text (append-only). See decisions.md D39. mcp.md, api.md, architecture.md, boundaries.md, decisions.md (D39), AGENTS.md, docs/engine-guide-zh.md, examples/sample-game/README.md, README.md, package.json, all workspace package.json files, apps/mcp-server (src + tests), apps/pack (src + tests), apps/dev (src + vite config), packages/core (schema/save.ts, content/bundle.ts, content/loader.ts + tests), packages/mcp (namespaces + tests), packages/render (src + tests)
2026-08-13 Animation state machines + smooth movement (D38) — MCP surface now 116 tools / 34 resources / 15 prompts / 21 namespaces. One additive creation-first namespace mirroring sprite.*/sound.*/particle.*/shader.* (D26/D34): animation.* (content kind animation, content/animations/<id>.yaml — {id, initial, states: [{id, spriteRef, fps?, loop?}], transitions?: [{from, to, on?}]} zod strict; a state machine over sprite defs — each state references a sprite def whose frames animate at the state's fps (default 10), loop: false plays once then holds; transitions is an optional authoring aid the runtime does NOT enforce). The state machine is driven by an entity's anim?: string field (behavior-language set-state player.anim "dance" / set-state player.anim null — null is a supported clear, no language change needed) with render-layer motion inference fallback ("walk" while moving, "idle" when still, else initial); an entity whose spriteRef matches an animation def id uses it, otherwise rendering is unchanged. Namespace: validate/create/update/list/get + animation://catalog/animation://{id} resources + animation.create-prompt, exact shapes/errors in the section above. The WS snapshot frame gains an additive animations: AnimationDef[] catalog (same pattern as particles/shaders/shops). server.orient lists the new tools. Smooth movement is render-layer-only (engine stays authoritative instant grid-jumps — moveTo/IntentSystem.move untouched): the render viewport tweens consecutive known tile coords over MOVE_DURATION_MS (250 ms, easeOutQuad) with walk/idle motion inference driving the animation state; no protocol changes. See decisions.md D38. mcp.md, api.md, decisions.md (D38), AGENTS.md, packages/core (schema/animation.ts, schema/index.ts, index.ts), packages/mcp (namespaces/animation.ts, index, server), packages/render (tween.ts, viewport, sprite-draw, sync), apps/mcp-server (ws-broadcast, tests), examples/sample-game (content/animations/player.yaml, content/sprites/player-walk.yaml)
2026-08-13 WS snapshot assets on-demand (D37) — NO MCP surface change (20 namespaces / 111 tools / 32 resources / 14 prompts held). Protocol evolution in the WebSocket world-sync channel only (a render-viewport transport, NOT an MCP namespace): the snapshot frame's assets field (D31 — full base64 table in EVERY snapshot) is replaced by a metadata-only assetsIndex (Record<assetId, {kind, mime, size}> — no bytes); a new client→server {"type":"assets-request","ids":string[]} message (bounded — the client coordinator batches ≤ 64 ids, the server truncates over the cap) is answered per-client with a new {"type":"assets","assets":Record<assetId, AssetEntry{kind,mime,data}>} frame (requested ids only, unknown ids ignored, empty request → empty response). Versioned protocol change: old clients reading snapshot.assets break — connectWs now reads assetsIndex, derives the wanted ids from the snapshot's presentation (map.tiles + sprite image layers + sound.src — pure snapshotAssetRefs), dedupes in-flight/loaded ids (AssetCoordinator, pure node-testable), and feeds {type:"assets"} responses into the viewport/sound via the existing setAssets/async-load paths (late-arriving assets re-render through the viewport's existing redraw-on-ready). New ws-broadcast tests: snapshot carries assetsIndex without base64, request→response roundtrip, unknown ids ignored, empty request → empty assets:{}, 64-id truncation. See decisions.md D37. mcp.md, api.md, decisions.md (D37), AGENTS.md, packages/render (sync/ws, sync/asset-coordinator, sync/asset-refs), apps/mcp-server (ws-broadcast + tests)
2026-08-13 Mod packaging + browser Mod execution + installer generation (D36) — NO MCP surface change (111 tools / 32 resources / 14 prompts / 20 namespaces held). Mods are code, not data (D6), so Mod packaging touches no MCP surface: no new namespace (pack.* still deliberately does not exist — packaging stays an offline build-time tool), no tools, no resources, no prompts, no content-kind registration, no protocol changes. What changed is the engine's Mod mechanism: ContentBundle gains an additive mods?: Record<modId, {manifest: {id, version}, code}> (zod-validated; BUNDLE_VERSION stays 1 — old bundles load unchanged), ModLoader gains loadFromSource(source, id, version?) + reloadFromSources(sources) (ESM source executed via a base64 data:text/javascript dynamic import — the SAME mechanism in Node ≥ 20 and browsers, zero fs access, validation/binding identical to file mods), and createEngine({bundle}) boots packaged Mods from bundle.mods when no modsDir is configured (modsDir wins when both are given). The mod.* namespace surface is unchanged — mod.list/mod.status/mod.reload already read engine.mods, which now reflects bundled Mods in bundle mode (all-at-once reload). farm-pack gains --mods <dir> (packages Mods into bundle.mods) and --installer (implies --desktop; writes an electron-builder.yml + dist/dist:win scripts so the desktop shell can build installers). See decisions.md D36. mcp.md, api.md, decisions.md (D36), AGENTS.md, packages/core (content/bundle.ts, mods/loader.ts, engine.ts, index.ts), apps/pack (src/index.ts, src/desktop.ts, test/pack.test.ts), package.json, examples/sample-game/README.md, docs/engine-guide-zh.md
2026-08-13 In-game UI + player-intent action extension (D35) — MCP surface held at 111 tools / 32 resources / 14 prompts / 20 namespaces. player.intent's inputSchema action enum extends from move/interact to move/interact/buy/sell/talk/choose/select-seed (the tool count stays 1 — no new tools, no new resources/prompts). New args on the schema: itemId/qty/shopId (buy/sell, defaults qty 1), npcId/tile (talk), index (choose), seedItemId (select-seed). Exact action semantics and error strings are documented in the player.* section above (D35) — buy/sell delegate to EconomySystem with the transaction as result detail, talk starts an NPC's dialogue (from the NPC entity's new dialogue field — sample mayor carries dialogue: "mayor"), choose advances the active dialogue, select-seed sets/clears player.selectedSeed. The WS snapshot frame gains an additive shops: ShopDef[] catalog (D35 — the dev UI shop panel reads buy/sell rows from it in ws mode; local mode reads the registry) and the server-side intent gate accepts all seven actions (message shapes unchanged). No other tool/resource/prompt signatures change. See decisions.md D35. mcp.md, api.md, decisions.md (D35), AGENTS.md, packages/core (systems/intent.ts, schema/building.ts, systems/placement.ts), packages/mcp (namespaces/player.ts), packages/render (viewport, sync/local, sync/ws), apps/mcp-server (ws-broadcast, tests), apps/dev (index.html, src/main.ts, src/ui.ts + ui.test.ts), examples/sample-game (content/buildings/shop.yaml, content/sprites/shop.yaml, mods)
2026-08-13 Render-layer completions (D34) — MCP surface now 111 tools / 32 resources / 14 prompts / 20 namespaces. Two additive creation-first namespaces mirroring sprite.*/sound.* (D26): particle.* (content kind particle, content/particles/<id>.yaml — {id, emitRate?, lifetime?: {min,max}, speed?: {min,max}, angle?, spread?, gravity?, size?: {min,max}, colors?, fade?} zod strict; pure-visual emission profile; the render layer spawns event-triggered bursts from the particle bus event, which content fires via the existing behavior-language emit-event action) and shader.* (content kind shader, content/shaders/<id>.yaml — {id, filter: blur\|tint\|grayscale\|sepia\|hue\|brightness, params?} zod strict; parameterized BUILT-IN filters, NO GLSL in content; referenced from SpriteDef.filters and the weather visual mapping). Each namespace: validate/create/update/list/get + catalog/{id} resources + create-prompt, with the exact shapes/error conventions documented in their sections. No other tool/resource/prompt signatures change: particles/shader are render-layer + content-kind only — no engine systems, no hooks, no protocol changes (the WS snapshot frame only gains additive optional particles/shaders catalogs; the renderer also gains entity text labels via the plain label entity field and sprite-sheet frame crops — both outside the MCP surface). server.orient lists the new tools. See decisions.md D34. mcp.md, api.md, decisions.md (D34), AGENTS.md, packages/core (schema/particle.ts, schema/shader.ts, schema/sprite.ts), packages/render (viewport, particles, filters, weather, labels, sprite-draw, sync), packages/mcp (namespaces/particle.ts, namespaces/shader.ts, index, server), apps/mcp-server (ws-broadcast, tests)
2026-08-13 Core mechanism completions (D33) — MCP surface now 101 tools / 28 resources / 12 prompts / 18 namespaces. One additive tool: npc.path (debug A* grid pathfinding query — {npcId, to:{x,y}} → {npcId, from, to, path: [{x,y},...]} including start and goal, or {npcId, path: null, reason: "unreachable"\|"out of bounds"\|"blocked"}; errors npc.path: npcId must be a non-empty string / movement: unknown npc: <id> / npc.path: to must have integer x and y; pure query — never moves the npc; backs MovementSystem.findPath). No other tool/resource/prompt signatures change: map walkability is data-driven (optional MapDef.walkable?: Record<tileId, boolean>, declared-true overrides the UNWALKABLE_TILES fallback — zero content change for existing maps; consumed via the new shared isWalkableTile helper in map-grid, replacing the direct constant checks in movement/intent/placement buildable "map"), the behavior language gains the add incremental action ({action:"add", path, value} — set-state stays absolute; missing field = 0; non-numeric field throws; value must be finite), a new single-file content kind interact (content/interact.yaml {id, order: [harvest, water, sow, till]} — content-izes the player interact priority ORDER; applicability stays engine mechanism; NO MCP tools — single-file-kind weather precedent), and save.* covers TimeSystem (optional additive time: {day, season, tickInDay} in WorldSnapshot — old snapshots remain valid, CURRENT_SAVE_VERSION stays 1; snapshot captures TimeSystem.get(); restore calls time.set(day, season, tickInDay)). interact is registered as a content kind but the sample game's def carries the default order, so no MCP authoring tools were added. See decisions.md D33. mcp.md, api.md, decisions.md (D33), AGENTS.md, packages/core (map-grid, movement, intent, placement, time, save, behavior-runtime, pathfinding, schema), packages/mcp (npc.ts, behavior.ts, dialogue.ts, server.ts), apps/mcp-server/test/client-surface.test.ts, apps/mcp-server/test/sample-game.test.ts, packages/core/test, packages/mcp/test/npc-namespace.test.ts, examples/sample-game/content
2026-08-13 Desktop packaging (D32) — NO MCP surface change (100 tools / 28 resources / 12 prompts / 18 namespaces held). Packaging is an offline build-time tool; the desktop shell adds no runtime engine capability — the Electron app serves the exact same bundle-mode static site over a local HTTP server. Nothing MCP-visible changes: no new namespace (pack.* still deliberately does not exist), no tools, no resources, no prompts, no content-kind registration, no protocol changes. The engine-side mechanisms the shell wraps are unchanged (ContentBundle + ContentLoader.loadBundle + createEngine({bundle}) + LocalSync). See decisions.md D32. mcp.md, api.md, decisions.md (D32), architecture.md, AGENTS.md, examples/sample-game/README.md, docs/engine-guide-zh.md, apps/pack/src/index.ts, apps/pack/src/desktop.ts, apps/pack/test/pack.test.ts, package.json, pnpm-workspace.yaml
2026-08-13 Dev-app/packaged-game day driver fixed (D-op2) — NO MCP surface change (100 tools / 28 resources / 12 prompts / 18 namespaces held). The apps/dev "Advance day" button (reused verbatim by the packaged static site) previously called time.advance — the D23 clock mover — so a day jump moved the clock and fired day-start triggers/weather but never advanced GrowthSystem; in the packaged game a planted crop never progressed past its first stage. It now calls engine.tick(dayLengthTicks) — the D23 simulation driver (the same path the headless core's engine.tick and MCP time.tick use) — stepping time AND growth together. No engine-core/schema/MCP changes. Regression guard: the apps/pack packGame integration test now asserts the full sow → water → two tick(480) → grown cycle incl. spriteRef progression (parsnip-sprout → parsnip-grown); E2E (headless chromium) re-verified the packaged site's asset-rendered tiles/crops/player and that a watered parsnip reaches the grown sprite in the browser. api.md, mcp.md, apps/dev/src/main.ts, apps/pack/test/pack.test.ts, AGENTS.md
2026-08-13 Asset pipeline (D31) — NO MCP surface change (100 tools / 28 resources / 12 prompts / 18 namespaces held). Binary assets (content/assets/: .png/.ogg) are filesystem content, not a content kind and not an MCP namespace — there is deliberately no assets.* namespace, no new tools, no new resources/prompts, no schema-kind registration, and no protocol-message changes (the WS snapshot frame only gains an additive assets field). The MCP-visible changes are schema-shape only on existing content kinds: sprite layers accept a third shape {shape:"image", src: <assetId>, x?, y?, w?, h?} (creation tools sprite.validate/create/update accept it; src is validated as a non-empty string — the loader resolves the id against content/assets/ at render time, and a missing asset degrades to the shape fallback), and sound.src is now a real audio asset id (previously reserved). map gains an optional tiles mapping (not MCP-created — maps are authored YAML, map.* is read-only). Rationale (D31.8): binary files are not MCP-creatable in a JSON-tool workflow, not introspection targets (the snapshot assets field is the debug surface), and have no runtime behavior to drive — the "MCP manipulability" arm of the tri-criterion fails, so no namespace is built. See decisions.md D31. mcp.md, api.md, decisions.md (D31), architecture.md, boundaries.md, AGENTS.md, examples/sample-game/README.md, docs/engine-guide-zh.md
2026-08-12 Game packaging (D30) — NO MCP surface change (100 tools / 28 resources / 12 prompts / 18 namespaces held). Packaging is an offline build-time tool (apps/pack, farm-pack), not a runtime capability, so it deliberately gets no MCP namespace: pack.* tools would be a debug/creation surface (D3) for a step that has no runtime engine counterpart — the engine-side mechanism it serves IS exposed (the ContentBundle type + ContentLoader.loadBundle + createEngine({bundle}), see api.md §2/§9). The MCP creation namespaces' write helpers gained a guard: contentDir is now string \| undefined on the engine (bundle-mode engines are read-only) and the write helpers throw a clear error on undefined — an error result through the gateway, never a crash; surface counts unchanged. See decisions.md D30. mcp.md, api.md, decisions.md (D30), architecture.md, AGENTS.md, examples/sample-game/README.md, docs/engine-guide-zh.md
2026-08-12 Tier 3 content round (D29) — NO MCP surface change (100 tools / 28 resources / 12 prompts / 18 namespaces held). The only engine change is the onPlayerIntent hook (HOOK_NAMES 15→16, wired in engine.wireEvents from the player-intent bus event; fires per IntentSystem.handle call, success AND failure, with HookContext.payload = the intent result {action, tile, ok, error?, detail?}). Hooks are NOT MCP surface (D-op2) — the hook exists to serve the "Mod hook demand" arm of the tri-criterion: Mods (hooks-only, D6/D13) cannot subscribe to the raw player-intent bus event that content matches via on: "player-intent", and the intent payload is transient (not readable from world state), so the D25.4 no-hook stance is reversed (see decisions.md D29.2). Sample-game content demonstrates the split: the fishing Mod hooks onPlayerIntent to settle catches (fish item — existing item kind, mod:fish-caught/mod:fish-too-tired custom events), and the fishing-treat state machine matches mod:fish-caught for a reward. No new namespaces, no new content kinds, no tool/resource/prompt signature changes. mcp.md, api.md, decisions.md (D29), boundaries.md, AGENTS.md, examples/sample-game/README.md
2026-08-12 server.enable_subsystem/server.disable_subsystem tools implemented (closes the D9 contract gap — the tools were spec'd in this section since the walking skeleton; surface now 100 tools / 28 resources / 12 prompts / 18 namespaces). Two new server.* tools wrapping the gateway's existing enable(name)/disable(name): server.enable_subsystem {name} → {name, enabled: true}, server.disable_subsystem {name} → {name, enabled: false}. Errors: non-string/empty name → <tool>: name must be a non-empty string; unknown namespace → <tool>: unknown subsystem: <name> (the gateway's McpGatewayError throw on a missing namespace is caught and converted, consistent with other namespaces' errorResult patterns); no gateway option → <tool>: gateway not available. Disabling a namespace removes its tools/resources/prompts from listTools/listResources/listPrompts and makes its tools reject calls (namespace "<name>" is disabled); server.info/server.status/server://status reflect {name, enabled: false}; server.enable_subsystem restores them. Guard rail (documented decision): server.disable_subsystem {name:"server"} is rejected with server.disable_subsystem: cannot disable the server subsystem — the server namespace hosts the very tool used to re-enable it. State is per-gateway in-memory (D9 — no persistence, no engine-system impact). server.orient lists the new tools automatically when gateway-backed (fallback static list updated). time/crop/server used as the enable/disable test subjects. mcp.md, api.md, decisions.md, AGENTS.md, apps/mcp-server/test/client-surface.test.ts, packages/mcp/test/server-namespace.test.ts
2026-08-12 rng.* namespace implemented — deterministic seeded RNG (D28; surface now 98 tools / 28 resources / 12 prompts / 18 namespaces). New engine mechanism mirroring LÖVE's love.math.RandomGenerator: packages/core/src/random.ts exports RandomSource ({next() /* [0,1) */, nextInt(max) /* [0,max) */, setSeed(seed), state(): {seed, calls}}) + seedRandom(seed) (mulberry32-style, zero deps — same seed → identical sequence) + randomSeed() (unseeded default from Math.random + Date.now). createEngine({seed?, ...}) seeds the facade's readonly rng: RandomSource; SystemInitCtx gains optional rng?: RandomSource (passed at init). WeatherSystem's default RNG now resolves ctx.rng when available (constructor-injected WeatherRng still wins for tests); PlantingSystem's harvest drop chance draws from ctx.rng.next() instead of bare Math.random — the engine's only two randomness consumers now share one seeded source. MCP surface: rng.get (no args → {seed, calls} — the current anchor + draw count), rng.set ({seed} finite number → {seed, calls: 0}, subsequent world randomness deterministic from that seed; non-finite/non-number → error result rng.set: seed must be a finite number), resource rng://state (same shape), no prompts (debug surface, D3). Save integration: WorldSnapshot gains optional top-level rngState?: {seed, calls} (zod optional — old snapshots remain valid, migration-free); save.snapshot includes it from ctx.rng, save.restore re-anchors via setSeed(rngState.seed) with calls reset to 0 (draw replay not built — documented). See decisions.md D28 (full LÖVE-gap adjudication) and api.md §1/§4/§7. mcp.md, api.md, decisions.md (D28), boundaries.md, AGENTS.md
2026-08-12 sprite.* and sound.* namespaces implemented — presentation pipeline data/protocol half (D26; surface now 96 tools / 27 resources / 12 prompts / 17 namespaces). Two new content kinds: sprite (content/sprites/<id>.yaml, spriteSchema — zod strict; {id, frames: [{name?, durationTicks?, layers: [{shape:"rect"\|"circle", x, y, w?/h? or r?, fill:"#rrggbb"}]}]}, programmatic draw instructions, no atlas/image in v1) and sound (content/sounds/<id>.yaml, soundSchema — zod strict; {id, synth?: {wave:"sine"\|"square"\|"triangle"\|"sawtooth", freq, durationMs, gain?, slideTo?}, src?} with at least one of synth/src). Both namespaces mirror item.* exactly (creation-first): sprite.validate/create/update/list/get + resources sprite://catalog/sprite://{id} + prompt sprite.create-prompt; sound.validate/create/update/list/get + resources sound://catalog/sound://{id} + prompt sound.create-prompt — validate against the schema (no write on failure), write content/<kind>/<id>.yaml, register live (create registers/overwrites, update removes then re-registers). Exact shapes: sprite.list → {sprites:[{id, frameCount}]}; sound.list → {sounds:[{id, hasSynth, hasSrc}]}; catalog resources → {sprites:[defs...]}/{sounds:[defs...]}; {id} resources → one def (missing → MCP error sprite not found: <id>/sound not found: <id>). WS protocol: snapshot frame gains sprites: SpriteDef[] and sounds: SoundDef[] (from registry.list at snapshot time — the render viewport needs the draw/audio instructions in-band); new client→server {"type":"input","input":string} message → the server emits an input bus event {input} broadcast as an event frame (behavior-language-matchable on:"input"), and key:ArrowUp/ArrowDown/ArrowLeft/ArrowRight additionally drive IntentSystem.handle("move", {tile}) one tile from the player's current position (player-centered); unknown prefixes/keys are broadcast-only; a non-string/empty input never crashes — a synthesized input error frame {input, ok:false, error:"input must be a non-empty string"} is broadcast instead. Behavior language: play-sound upgrades from a no-op to emitting the sound-play bus event {soundId} (documented mechanism; render layer plays in round 2). New schema/def fields: CropDef.stages[].spriteRef?, BuildingDef.spriteRef?, AnimalDef.stages[].spriteRef? (all optional strings — missing = renderer falls back to shape drawing); systems maintain entity appearance: PlantingSystem.sow sets plot.spriteRef (crop stage 0), GrowthSystem sets plot.spriteRef on crop stage advance and animal.spriteRef on spawn + animal stage advance, PlacementSystem.place copies building.spriteRef onto the entity, and the engine seeds player.spriteRef = "player". No new systems and no new hooks (sprites/sounds are content kinds; the input mapping wires into the existing IntentSystem from the broadcast server). See the sprite.*/sound.* sections above and decisions.md D26. mcp.md, api.md, decisions.md (D26), boundaries.md, architecture.md, AGENTS.md
2026-08-12 player.* namespace implemented — player input via IntentSystem (D25; surface now 86 tools / 23 resources / 10 prompts / 15 namespaces). New engine primitive IntentSystem (id "intent", auto-booted): handle(action, args) → IntentResult {action, tile, ok, error?, detail?} for move {tile:{x,y}} (validate + set player.x/player.y, reusing the shared map-grid/placement helpers — MovementSystem.moveTo untouched) and interact {tile:{x,y}} (smart context action with the v1 priority order: mature crop → harvest; planted & not watered → water; tilled & empty & seed → sow with the FIRST seed-kind inventory item; not tilled → till; else error). Every handle call emits the player-intent bus event (successes AND failures — HUD/viewport feedback depends on it). MCP surface: one tool player.intent ({action, tile}; errors as errorResult player.intent: <issue>) + one resource player://state (live player entity JSON). WS protocol: snapshot frame gains time: {day, season, tickInDay} (fixes the viewport HUD stuck on "booting…" — ws.ts applyTime now also reads snapshot.time); new client→server {"type":"intent","action","args"} message → IntentSystem.handle → player-intent event frame broadcast (bad input → synthesized error frame, never a crash). Sync handles: LocalSyncHandle/WsSyncHandle both gained intent(action, args) (local delegates to the engine IntentSystem; ws sends the intent message) — both dev modes are now playable. viewport.tileAt(clientX, clientY) → screen→tile. apps/dev gains an Interact/Move toolbar + canvas-click intents + HUD (day/season, seed counts, gold, auto-clearing intent feedback). No schema/content/hook changes; prompts unchanged (10). mcp.md, api.md, architecture.md, boundaries.md, decisions.md (D25), AGENTS.md
2026-08-12 WS multi-viewport sync implemented (D8/D24) — the final Tier 2 item. apps/mcp-server gains an optional --ws-port <port> / FARM_WS_PORT WebSocket broadcast channel running in the same process (zero-flag behavior unchanged). No MCP surface change — the channel is separate, NOT a namespace: 85 tools / 22 resources / 10 prompts / 14 namespaces stay identical. Protocol (transport section above): snapshot on connect (map def + world entities) and on {"type":"sync"}, event frames broadcast to all clients for every bus emission, defensive JSON serialization (non-serializable payloads skipped with a log line, never a crash). packages/render gains sync/ws.ts connectWs (same handle shape as connectLocal) and snapshot entity drawing in the viewport (plots with tilled/watered/crop-stage colors, buildings as footprint blocks, npc/player/animal dots — no art). apps/dev is parameterized via URL query (?contentDir=/?modsDir= default to the repo roots + LocalSync; ?sync=ws&url=ws://... renders a headless core's world instead of booting one). Core change: engine.events (readonly WorldEventBus) exposed on the facade so the broadcast server can subscribe (api.md §1). mcp.md, api.md, architecture.md, boundaries.md, decisions.md, AGENTS.md
2026-08-12 crop.water fixed to mirror the mechanism (MCP-surface only; @farm/mcp; surface unchanged at 85 tools / 22 resources / 10 prompts). The debug tool previously toggled the plot's watered flag (true→false→true), which broke the MCP-driven farming loop (water → tick → water → tick… flipped watered back to false, so waterRequired stages never advanced). It now calls PlantingSystem.water(x,y) (the SET mechanism — same precedent as crop.stage→GrowthSystem.forceStage and crop.till/crop.sow/crop.harvest→PlantingSystem): result stays {plotId, x, y, watered} (always true after a successful water; watering an already-watered plot keeps it watered). Error paths follow crop.till's conventions — shared resolveCoordArgs (crop: provide plotId or x/y coordinates, crop: no plot found: <plotId>), system errors pass through (planting: no plot at (x,y), planting.water: tile (x,y) is not tilled). No engine-core changes, no new tools/hooks/events. mcp.md, api.md
2026-08-12 crop.till / crop.sow / crop.harvest debug tools added (the core farming loop till → sow → water → harvest is now drivable through MCP; surface now 85 tools / 22 resources / 10 prompts). crop.till {x?, y?, plotId?} → {plotId, x, y, tilled} — wraps PlantingSystem.till(x,y) (idempotent: creates the plot entity if absent, re-tills otherwise); no coords/plotId → error result crop: provide plotId or x/y coordinates. crop.sow {x?, y?, plotId?, seedItemId} → {plotId, x, y, cropItemId, seedItemId} — wraps PlantingSystem.sow(x,y,seedItemId): consumes 1 seed from player.inventory (emitting item-use), sets cropItemId to the crop def id resolved from the seed; missing/blank seedItemId → error result crop.sow: seedItemId must be a non-empty string; system errors pass through (planting.sow: tile (x,y) is not tilled, planting.sow: no <seed> in inventory, planting: no plot at (x,y), …). crop.harvest {x?, y?, plotId?} → {plotId, x, y, cropItemId, drops: [{itemId, qty}]} — wraps PlantingSystem.harvest(x,y): requires the final stage, rolls drops, grants them to player.inventory (emitting item-acquire per drop), emits crop-harvest, clears the crop fields (plot stays tilled); system errors pass through (planting.harvest: plot <id> has no crop, planting.harvest: crop <id> on plot <id> is not mature (stage N of M)). All three accept {x, y} or {plotId} (identical coordinate/plotId validation to crop.stage/crop.water; plotId resolves via plotIdFor). Debug tools (D3) wrapping the PlantingSystem mechanism — no schema/content changes, no new hooks, no new events. mcp.md, api.md
2026-08-12 apps/mcp-server entry parameterized + examples/sample-game/ added (transport section above). The stdio entry serves any content/mods pair via exported resolveDirs({argv, env, cwd}) (CLI --content-dir/--mods-dir > env FARM_CONTENT_DIR/FARM_MODS_DIR > monorepo defaults; relative paths against cwd) and main(dirs) (auto-runs only under direct execution). examples/sample-game/ ships complete game content + a mod exercising every namespace; apps/mcp-server/test/sample-game.test.ts proves it boots and plays through MCP. mcp.md, api.md, architecture.md
2026-08-12 Tick loop + animal.* namespace implemented (Tier 2 items 5 & 6 final; surface now 82 tools / 22 resources / 10 prompts). Tick loop (D23): engine.tick(dt) is the headless core's explicit simulation driver (dt in ticks, 1 tick = 1 engine time unit, dayLengthTicks from the time def) — it advances TimeSystem (firing day-start/season-change boundaries as before) and then runs every system's step(dt) in registration order. TimeSystem gained step(dt) which emits the new time-tick bus event {day, season, tickInDay} after each advance; GrowthSystem.step accumulates day-fractions and advances crops/animals on day boundaries. New tool time.tick ({ticks?}, default 1) → the post-step time state {day, season, tickInDay}; it is semantically distinct from time.advance (clock-only, no systems step, no time-tick). Optional auto-tick: createEngine({ tickIntervalMs, tickDeltaTicks? }) drives tick via a node:timers interval (default 1 tick per interval, unref'd, cleared on engine.stop()); OFF by default so tests stay deterministic. onSchedule intra-day form: at: { tickInDay: n } (n ∈ [0, dayLengthTicks)) added to the schedule-at union — evaluated on the time-tick event (schema requires at least one of day/season/tickInDay in the object form; the existing "day-start"/"season-change"/{day?, season?} forms are unchanged and still evaluated only on those boundary events). animal.* namespace — 9 tools: animal.validate/create/update/list/get (definition lifecycle against animalSchema, mirroring crop.* exactly — write content/animals/<id>.yaml, create registers/overwrites, update removes then re-registers) + debug animal.spawn ({animalId,x,y} → {id:"animal:<animalId>:<n>", type:"animal", animalId, x, y, stageIndex:0, daysInStage:0, fed:false}; unknown def → growth: unknown animal def: <id>; bad coords → growth: x and y must be non-negative integers) + animal.feed ({id} → {id, animalId, fed:true}; unknown → growth: unknown animal: <id>; emits animal-fed {animalId, animalDefId}) + animal.stage ({id, stageIndex} → {id, animalId, animalDefId, stageIndex, stage}; delegates to GrowthSystem.forceAnimalStage) + animal.entities (→ {animals:[...]}). Resources animal://catalog + animal://{id} (missing → MCP error animal not found: <id>). Prompt animal.create-prompt. AnimalDef (content kind animal, content/animals/<id>.yaml): { id, stages: [{name, days, feedRequired}], produces: [{itemId, qty, chance?}] } — the growth cycle + produce table (data only); feed/produce rules are behavior-language content. GrowthSystem step advances both type:"plot" (CropDef, zero regression) and type:"animal" entities (AnimalDef): per day, a feedRequired stage gates on the entity's fed flag (mirroring watered); non-final stages accumulate daysInStage then advance stageIndex (resetting daysInStage/fed, emitting animal-grow); a mature (final-stage) animal emits animal-produce per eligible day and its fed resets. New hooks onAnimalGrow/onAnimalProduce wired in engine.wireEvents; animal-fed is bus-only (no hook — behavior-language-matchable). See api.md §4/§7 and decisions.md D23. mcp.md, api.md, decisions.md, boundaries.md, AGENTS.md
2026-08-12 Initial draft (pre-implementation) mcp.md, api.md, architecture.md, decisions.md, AGENTS.md
2026-08-12 Walking skeleton implemented: server.*/map.*/time.* namespaces live (7 tools, 3 resources, 1 prompt). item/crop/shop/save/behavior/mod remain spec-only. server.enable_subsystem/disable_subsystem deferred (gateway enable/disable exists, no tools yet). Resource contract: uri template + read(params) (see note above). mcp.md
2026-08-12 Client-surface shapes confirmed by integration tests (apps/mcp-server/test/client-surface.test.ts, 12 cases): success results omit isError (absent, not false); map.list returns {maps:[...]} (id/width/height/tilesetRef summaries); map.get returns the map def plus a player key (spawn entity, null if absent) and defaults to the first map when mapId omitted; time.* args validated (missing/negative ticks → error result). mcp.md
2026-08-12 item.* namespace implemented (creation-first, D3): item.validate/item.create/item.update/item.list/item.get + debug item.query_inventory/item.give; resources item://catalog + item://{id}; prompt item.create-prompt. Creation tools validate the JSON def against itemSchema (no write on failure), then write content/items/<id>.yaml (YAML stringify) and register live directly against the registry — mirrors D13 hot-reload (create registers/overwrites; update removes then re-registers). Exact shapes: item.list → {items:[{id,name,kind}]}; item.validate/item.create/item.update/item.get → normalized ItemDef JSON or error result (item not found: <id>); item.query_inventory → {inventory:{itemId:qty}}; item.give → {itemId,qty,total}; item://catalog → {items:[defs...]}; item://{id} → one def (missing id → MCP error). Duplicate item.create overwrites. Inventory lives on the player entity (player.inventory); give/take emit item-acquire/item-use bus events → hooks onItemAcquire/onItemUse. mcp.md, api.md
2026-08-12 crop.* namespace implemented (creation-first, D3): crop.validate/crop.create/crop.update/crop.list/crop.get + debug crop.stage/crop.water; resources crop://catalog + crop://{id}; prompt crop.create-prompt. Creation tools mirror item.* exactly: validate the JSON def against cropSchema (no write on failure), write content/crops/<id>.yaml (YAML stringify), register live (create registers/overwrites; update removes then re-registers). Exact shapes: crop.list → {crops:[{id,seedItem,stageCount}]}; crop.validate/crop.create/crop.update/crop.get → normalized CropDef JSON or error result (crop not found: <id>); crop.stage → {plotId,x,y,cropItemId,stageIndex,stage} (accepts {x,y,stageIndex} or {plotId,stageIndex}; delegates to GrowthSystem.forceStage, fires onCropGrow); crop.water → {plotId,x,y,watered} (accepts {x,y} or {plotId}; toggles the plot's watered); crop://catalog → {crops:[defs...]}; crop://{id} → one def (missing id → MCP error). cropItemId on a plot is the crop def id, not the seed item id. Growth stage advance itself is simulation (GrowthSystem.step), not an MCP tool — MCP only forces stages for debugging (D3). mcp.md, api.md
2026-08-12 shop.* namespace implemented (creation-first, D3): shop.validate/shop.create/shop.update/shop.list/shop.get + debug shop.buy/shop.sell; resources shop://catalog + shop://{shopId}; prompt shop.create-prompt. Creation tools mirror item.*/crop.* exactly: validate the JSON def against shopSchema (no write on failure), write content/shops/<id>.yaml (YAML stringify), register live (create registers/overwrites; update removes then re-registers). Exact shapes: shop.list → {shops:[{id,buyCount,sellCount}]}; shop.validate/shop.create/shop.update/shop.get → normalized ShopDef JSON or error result (shop not found: <id>); shop.buy → {shopId,itemId,qty,total,gold} or error result (unknown shop, item not sold, insufficient gold, non-positive qty); shop.sell → {shopId,itemId,qty,total,gold} or error result (unknown shop, item not bought, insufficient inventory); shop://catalog → {shops:[defs...]}; shop://{shopId} → one def (missing id → MCP error). Buy/sell delegate to EconomySystem.buy/sell (each fires onTransaction via the transaction bus event; buy also emits item-acquire, sell also emits item-use). Buy/sell convention: sells = shop sells to the player (player pays sells[].price), buys = shop buys from the player (player earns buys[].price). stock is optional and informational in this slice (no depletion/restock). mcp.md, api.md
2026-08-12 save.* namespace implemented (mechanism only, D14): save.snapshot (no args → full WorldSnapshot JSON {format:"farm-save", version, savedAt, world: Entity[]}); save.validate ({snapshot} → {ok:true} or {ok:false, errors:[...]} — always a normal result; validation failure is a report, not an error result); save.restore ({snapshot} → {restored:N} or error result — validates, rejects future versions, migrates to the current version via registered migrations, then replaces ALL world entities; malformed input → error result invalid snapshot); save.migrate ({snapshot, fromVersion?, toVersion?} → the migrated snapshot or error result — no-op when versions equal, throws on backwards migration, versions without a registered migration are identity steps, so the v1 empty registry returns the snapshot unchanged after validation; non-positive fromVersion/toVersion → error result); resource save://state (latest snapshot, live — reflects mutations); prompt save.authoring-prompt (save format, versioning, migration authoring). The snapshot covers world entities only; TimeSystem state is not serialized in v1. Save policy (when to save, slots, autosave, UI) is creator content/Mods (D14) — no policy tools. save has no content files (content/saves/ does not exist). mcp.md, api.md
2026-08-12 behavior.* namespace implemented (v1 core: conditions + triggers, D15), 7 tools + 1 resource + 1 prompt. Creation tools mirror item.*/crop.*/shop.* exactly (tools named trigger.* per the contract, but the content kind is behavior — so files live in content/behaviors/<id>.yaml and the list key is triggers): validate the JSON def against triggerSchema (no write on failure), write the YAML via stringify, register live (create registers/overwrites; update removes then re-registers). Exact shapes: behavior.trigger.list → {triggers:[{id,actionCount}]} (registration order); behavior.trigger.validate/behavior.trigger.create/behavior.trigger.update/behavior.trigger.get → normalized TriggerDef JSON or error result (behavior.trigger.get: trigger not found: <id> / behavior.trigger.update: trigger not found: <id>); behavior.fire ({triggerId}) → {triggerId, fired} — fired: true when the condition evaluated true (actions ran, trigger-fire emitted, onTriggerFire hook fired), fired: false when the condition was false (no-op); unknown trigger → error result (unknown trigger: <id>; non-empty-string validation also errors). behavior.condition.validate ({condition}) → {ok:true} or {ok:false, errors:[...]} — always a normal result (mirrors save.validate); the expression is validated alone against conditionExprSchema. Resource behavior://triggers → {triggers:[defs...]} (exact-URI, no template). Prompt behavior.trigger-prompt → guided authoring (condition grammar, action set, path conventions). No deviations from the behavior.* section contract above beyond the documented shapes; condition evaluation semantics (path resolution convention, trigger-fire loop guard, action behavior) are in api.md §4. mcp.md, api.md
2026-08-12 mod.* namespace implemented (introspection only, D10; last Tier 1 subsystem), 3 tools + 1 resource, no prompts. The engine facade gained a read-only mods surface (api.md §1): engine.mods (the loaded ModStatus[], live) and engine.modsDir (`string undefined), plusengine.reloadMods(id?)behindmod.reload. Exact shapes:mod.list(no args) →{mods:[ModStatus...]}where eachModStatus = {id, version, loaded, hooks: string[], errors?: string[]}—versionfrom the Mod's inlinemanifest {id, version}(Mods are code atmods//index.mjs; the loader does NOT read acontent/mods//manifest.yamlor anycontentOverridesfield, so neither is surfaced — reported as-is rather than invented);mod.status({id}) → oneModStatusJSON or error result (mod not found: ; blank id →mod.status: id must be a non-empty string);mod.reload({id?}) →{reloaded: true, mods: ModStatus[]}with fresh statuses (unknown id → error resultmod not found: ; blank id →mod.reload: id must be a non-empty string when provided); resourcemod://{id}→ oneModStatus(missing id → MCP gateway errormod not found: ). **mod.reloadsemantics (v1)**: in-process re-import + re-bind, NOT a process restart —ModLoader.reload(modsDir, id?)unbinds that Mod's previously-registered hook functions (eachHookRegistry.onreturns an unsubscriber; the loader keeps per-mod unsubscriber records) and re-imports the Mod entry with a cache-busting?t=-query so changed.mjsfiles are actually re-read and re-bound without duplicate hook bindings. With noid, reload unbinds all recorded Mods and re-scansmodsDir(newly added Mod dirs are picked up; deleted Mods are unbound). A Mod that was never loaded successfully in this process cannot be reloaded by id (no entry record → treat as unknown). D13's authoritative path remains unchanged: code Mod changes are picked up for real by restarting the headless core (apps/mcp-server);mod.reloadis the in-process convenience for the dev loop. Errors during reload (bad import, bad manifest) surface asloaded: false+errorsin the returned statuses and inengine.mods. Deviation from the draft contract: themod.*` section lists no authoring prompt and none was added (consistent with the contract — introspection only).
2026-08-12 Behavior language v2 implemented (state machines + onSchedule time primitive, D15/D20; Tier 2 lead item), 7 tools + 1 resource + 1 prompt added to the behavior.* namespace (surface now 49 tools / 13 resources / 7 prompts). Separate content kind statemachine (content/statemachines/<id>.yaml, stateMachineSchema in schema/statemachine.ts) — not a discriminated union inside behavior — so behavior.trigger.* stays intact. New tools: behavior.statemachine.validate/create/update/list/get (creation-first, mirror trigger.* exactly: validate against stateMachineSchema with cross-field checks — initial/transitions[].from/to/onSchedule[].to must reference declared states, duplicate state ids rejected, each transition needs at least one of when/on; no write on failure; write content/statemachines/<id>.yaml; create registers/overwrites, update removes then re-registers) + debug behavior.statemachine.status ({machineId} → {machineId, state, started} — started: false with state = declared initial before the instance exists) + behavior.statemachine.transition ({machineId, to} → {machineId, from, to, transitioned} — force a transition regardless of declared conditions, fires onEnter/onExit and emits state-change; unknown machine/state → error result). Resource behavior://statemachines → {statemachines:[defs...]}. Prompt behavior.statemachine-prompt → guided authoring (states, transitions, schedule entries, quest-progress convention). Design decisions (D20): (1) separate kind vs behavior-union — separate keeps trigger tool names truthful and content dirs 1:1; (2) onSchedule (schedule entries over the existing day-start/season-change bus events) instead of a tick loop — the engine has no auto-driven tick, so no new game loop; intra-day timing awaits a tick loop; (3) TriggerSystem extended in place (id stays "trigger") so triggers and machines share one match pass and one re-entrancy guard; (4) machine state lives on a world entity {id: "sm:<machineId>", type: "statemachine", machineId, state} (lazily created on first transition) so save.* snapshots it for free and MCP inspects it. Each transition emits state-change {machineId, from, to} → new hook onStateChange. mcp.md, api.md, boundaries.md, decisions.md
2026-08-12 dialogue.* and weather.* namespaces specced (MCP-first, D-op1; Tier 2 items 2 & 3) before implementation. dialogue.*: 7 tools (5 lifecycle + debug start/choose), 2 resources (catalog + {id}), 1 prompt (create-prompt). weather.*: 2 debug tools (get/set), 1 resource (weather://state), no prompts — single-file content kind makes creation tools meaningless. Design decisions (D21) recorded in the sections above: dialogue state on a world entity dialogue:active; linear to nodes surface a synthesized Continue choice; available choices are when-filtered and choose indexes into that list; terminal/all-gated nodes auto-end; weather state on a boot-created weather entity; injectable RNG; initial weather = current season's first table entry. mcp.md
2026-08-12 placement.* and npc.* namespaces specced (MCP-first, D-op1; Tier 2 items 4 & 5) before implementation. placement.*: 9 tools (5 building lifecycle + debug place/remove/list/occupies), 3 resources (placement://buildings, placement://{id}, placement://placed), 1 prompt (create-prompt). npc.*: 5 debug tools (list/get/spawn/move/teleport), 1 resource (npc://{npcId}), no prompts (runtime-entity namespace — no content kind). Design decisions (D22) recorded in the sections above: BuildingDef buildable as "map"/{tiles}/"any" union; buildings as world entities; buildings-block-buildings v1 occupancy; placement-change event + onPlacementChange hook; MovementSystem resolving PlacementSystem from the host; tile walkability as a "water" tile-id convention; buildings block NPCs but NPCs do not block each other (v1); no onNpcMove hook (the npc-move bus event is behavior-language-matchable). mcp.md
2026-08-12 placement.* and npc.* namespaces implemented (Tier 2 items 4 & 5; surface now 72 tools / 20 resources / 9 prompts). placement.* — 9 tools: placement.building.validate/create/update/list/get (creation-first, mirror item.* exactly against buildingSchema — footprint positive ints, buildable as "map"/{tiles:[...]}/"any" union with non-empty tile lists, required blocksMovement boolean; no write on failure; write content/buildings/<id>.yaml; create registers/overwrites, update removes then re-registers) + debug placement.place ({buildingId,x,y} → placed entity {id:"building:<buildingId>:<x>:<y>", type:"building", buildingId, x, y, w, h, blocksMovement} or error result: placement: unknown building: <id>, placement: footprint out of bounds at (x,y), placement: tile (<cx>,<cy>) is not buildable for <id>, placement: footprint overlaps existing building <id>) + placement.remove ({x,y} → removed entity or error placement: no building at (x,y)) + placement.list (→ {buildings:[placed...]}) + placement.occupies ({x,y} → building entity or null). Resources placement://buildings → {buildings:[defs...]} + placement://{id} → one def (missing → MCP error building not found: <id>) + placement://placed → {buildings:[placed...]} (live). Prompt placement.create-prompt → guided building authoring. npc.* — 5 debug tools (D3): npc.list (→ {npcs:[{id,x,y}]}), npc.get ({npcId} → entity or error npc not found: <id>), npc.spawn ({id,x,y} → created entity; rejects blank id / bad coords / duplicate id), npc.move ({npcId,x,y} → {npcId, from, to} or error result: movement: unknown npc: <id> / movement: target out of bounds: (x,y) / movement: tile (x,y) is not walkable / movement: blocked by building <id> at (x,y)), npc.teleport ({npcId,x,y} → same shape, no bounds/walkability/blocking checks); resource npc://{npcId} → live entity (missing → MCP error). npc.* has no prompts (NPCs are runtime entities — no authored content kind). New content kind building (content/buildings/), new hook onPlacementChange wired to the placement-change bus event. Movement blocks on blocksMovement: true buildings read via PlacementSystem.occupies; tile walkability uses the "water" convention. mcp.md, api.md
2026-08-12 dialogue.* and weather.* namespaces implemented (Tier 2 items 2 & 3; surface now 58 tools / 16 resources / 8 prompts). dialogue.* — 7 tools: dialogue.validate/create/update/list/get (creation-first, mirror item.* exactly against dialogueSchema with cross-field checks — unique node ids, startNode/to/choice to must be declared nodes, choices+to mutually exclusive, non-empty choices; no write on failure; write content/dialogues/<id>.yaml; create registers/overwrites, update removes then re-registers) + debug dialogue.start ({dialogueId} → {dialogueId, nodeId, text, choices: [{text, to}]} — choices are the when-filtered available list; unknown → error result dialogue: unknown dialogue: <id>) + dialogue.choose ({index} → next node state; index into the available list; errors: dialogue.choose: index must be a non-negative integer, dialogue: no active dialogue, dialogue: choice index out of range: <index>; a terminal/all-gated node ends the dialogue — text delivered in the result, dialogue-end emitted, active entity removed). Resources dialogue://catalog → {dialogues:[defs...]} + dialogue://{id} → one def (missing → MCP error). Prompt dialogue.create-prompt → guided authoring (nodes/choices/when/actions). weather.* — 2 debug tools (D3): weather.get (no args → {weatherId, name?, season}) + weather.set ({weatherId} → {weatherId, name?, season, from?} — from present only on change; unknown id → error result weather: unknown weather id: <id>); resource weather://state → current state; no prompts (single-file content kind — no creation tools by design). Both systems' state persists on world entities (dialogue:active, weather), so save.* covers them. New hooks onDialogueStart/onDialogueEnd/onWeatherChange wired in engine.wireEvents. Deviation from the spec's parenthetical surface-count estimate (+1 dialogue resource; see the dialogue.* section — catalog + {id} follow the item.*/crop.*/shop.* pattern). mcp.md, api.md, boundaries.md, decisions.md