api.md 132 KB

Engine API Reference

The living API doc, organized by MCP service (MCP-first: mcp.md is the contract; internals exist to serve it). Every entry notes which MCP tools it serves.

Living-doc rule (D-op2): any interface change — MCP tool signature, engine API, data schema — MUST update this file and docs/mcp.md in the same change. Signatures below are the pre-implementation draft; they anchor to the MCP surface, so drift during implementation is expected and must be reflected here.

Conventions

  • All engine types are zod-derived from the normalized JSON shape (D4/D18).
  • Systems are pure functions over entity data (D11); they receive dt, mutate world, and are the only place simulation happens.
  • Mods never import these modules (D6); they see only HookContext.

1. Engine Facade (packages/core/src/engine.ts)

API Signature Serves
createEngine (opts: EngineOptions) => Engine — EngineOptions = exactly one content source — {contentDir: string} (fs, dev/authoring/headless) or {bundle: ContentBundle} (in-memory, packaged static site, D30) — plus modsDir?: string; systems?: System[]; tickIntervalMs?: number; tickDeltaTicks?: number; seed?: number server.info
engine.start() () => Promise<void> — load content (fs load with contentDir, or loadBundle with bundle — no fs access in bundle mode), validate, boot systems, load Mods (skipped when modsDir is absent); when tickIntervalMs is set, starts the optional auto-tick interval (D23). Seeding (D26/D29): the player entity is created with spriteRef: "player" (the engine's default appearance reference) plus the D29 field-level stamina/skills seed stamina: 100, skills: {} (plain data fields — no StaminaSystem/SkillSystem; the behavior language + Mods own the rules, D19) all creation tools
engine.stop() () => Promise<void> — orderly shutdown, flush per save policy, clear the auto-tick interval —
engine.tick(dt) (dt: number) => void — the authoritative simulation driver (D23): validates dt (positive number of ticks), then runs every system's step(dt) through SystemsHost in registration order. TimeSystem's step advances the clock (firing day-start/season-change boundaries as before) and emits the time-tick bus event {day, season, tickInDay}; GrowthSystem's step accumulates day-fractions against dayLengthTicks and advances crops/animals on day boundaries. Explicit stepping keeps headless MCP/debug/tests deterministic (no render loop — D8) time.tick
engine.rng RandomSource (readonly) — the engine's single deterministic RNG (D28): { next(): number /* [0,1) */, nextInt(max): number /* [0,max) */, setSeed(seed): void, state(): {seed, calls} }. Seeded from createEngine({seed}) or a fresh randomSeed() when omitted; systems draw from it via SystemInitCtx.rng, so same seed → same world randomness. save.snapshot carries rngState; save.restore re-anchors it. Seeds normalize to uint32; state().seed is the anchor, calls counts draws since it rng.*
engine.contentDir string \| undefined — content root directory (fs mode); undefined in bundle mode (D30). Creation tools write content/<kind>/ relative to it — a bundle-mode engine is read-only (the MCP creation namespaces throw a clear error on undefined) item.create / item.update
engine.modsDir string \| undefined — Mods root directory (mods/<id>/index.mjs); undefined when no Mods configured mod.*
engine.mods ModStatus[] — the loaded Mod statuses (live; replaced on reloadMods): { id, version, loaded, hooks: string[], errors? } mod.list / mod.status
engine.world World — entity store + queries debug tools
engine.registry ContentRegistry — validated content by id *.get / *.list / *.catalog
engine.assets ReadonlyMap<string, AssetEntry> (D31) — the binary asset table (a METADATA index since D37: entry = {kind: "image"\|"audio", mime} + either data (base64 bytes — fs-loaded and inline-bundle entries) or size/file (split-bundle entries, D37)); populated by the loader from content/assets/ (fs) or a bundle's assets/assetsIndex (bundle mode). Bytes are a transport concern, not engine state. Shared with registry.assets (same Map). Binary files are NOT a content kind — no contentSchemas entry, no MCP namespace sprite.create (id references), WS snapshot assetsIndex
engine.hooks HookRegistry — typed hook points mod.*
engine.systems SystemsHost — registered systems server.info
engine.mcp McpGatewayHandle — namespace registration gateway
engine.events WorldEventBus (readonly property) — the world event bus; read-only exposure so external subscribers (e.g. the WS broadcast server in apps/mcp-server) can on/onAny every bus emission (day-start, time-tick, crop-grow, npc-move, ...) without touching engine internals. emit remains reachable through the same object (matching the world/registry readonly-property precedent — readonly prevents reassignment, not method calls) WS sync channel
engine.reloadMods(id?) (id?: string) => Promise<ModStatus[]> — re-import + re-bind one Mod (or all) with cache-busting; updates engine.mods and returns the fresh statuses mod.reload

2. Content Pipeline (packages/core/src/content/)

Pipeline: YAML → normalize → zod validate → register (D18). YAML is authoring-only; everything after normalization is JSON.

API Signature Serves
ContentLoader load(dir: string) → Promise<LoadReport>; loadBundle(bundle: ContentBundle) → Promise<LoadReport> — re-validates every def against the per-kind content schema (defense in depth; structural invalidity throws not a valid nengine-bundle); watch(dir) — hot reload (D13). Assets (D31/D37): load also scans content/assets/ for *.png/*.ogg → registry.assets (id = filename without extension, base64 {kind, mime, data}; unreadable files skipped best-effort; no assets dir = empty table); loadBundle collects bundle.assets (inline base64 entries, D31) AND/OR bundle.assetsIndex (D37 metadata-only entries — {kind, mime, size, file?}, no bytes; inline entries win when both name an id and are augmented with the index's size/file); watch re-scans assets on changes under assets/ (same debounce) *.create / *.update (write side)
LoadReport { ok: boolean; items: { id, kind, status, errors? }[] } — identical per-item shape for load and loadBundle; asset collection is not reported as items (the table is read from registry.assets) *.validate
ContentRegistry get(kind, id), list(kind), all(), ids(kind); assets (Map<assetId, AssetEntry> — D31) + asset(id) *.get / *.list / *.catalog
normalize(kind, yamlDoc) YAML → internal JSON (drops comments, resolves refs) internal
validate(kind, json) zod parse against schema/<kind>.ts *.validate
ContentBundle / contentBundleSchema / BUNDLE_FORMAT / BUNDLE_VERSION the packaged content container (D30): { format: "nengine-bundle", version: 1, content: Record<string, unknown[]>, assets?: Record<string, AssetEntry>, assetsIndex?: Record<string, AssetIndexEntry>, mods?: Record<string, BundledMod> } — kind → array of validated defs, an optional D31 assets map (base64-inlined binaries, INLINE mode), an optional D37 assetsIndex map (metadata-only {kind, mime, size, file?} — the index invariant: carried by BOTH inline and split bundles; split bundles rely on it exclusively, assets absent and file = "nengine-assets/<id>.<ext>"), and an optional D36 mods map (modId → {manifest: {id, version}, code} — the Mod's entry source text, executed by the ModLoader in bundle mode), the SINGLE source of truth for packaged content (no YAML files, no Mod files ship; YAML parsing happens only at compile time in apps/pack). assetEntrySchema ({kind: "image"\|"audio", mime, data} strict) validates each inline asset entry; assetIndexEntrySchema ({kind: "image"\|"audio", mime, size: non-negative int, file?} strict) validates each index entry; assetByteSize(entry) (declared size or base64-derived) and assetFileExtension(mime) (png/ogg) are the metadata helpers; bundledModSchema ({manifest: {id: min1, version: min1}, code: string} strict) validates each mod entry. BUNDLE_VERSION stays 1 — assets/assetsIndex/mods are additive nengine-pack

3. World & Entities (packages/core/src/world/)

API Signature Serves
World create(id), get(id), query({ type?, area? }), remove(id), snapshot() map.tile, crop.stage, item.query_inventory
Entity { id: string; type: string; ...componentFields } — flat, schema-validated (D11) save.snapshot
WorldEventBus emit(type, payload), on(type, fn) behavior.fire, hooks

4. Systems (packages/core/src/systems/)

System interface:

interface System {
  id: string;
  init(ctx: SystemInitCtx): Promise<void> | void;
  step?(dt: number): void;                       // simulation tick
  on?(event: string, payload: unknown): void;    // world events
}

5. Mods (packages/core/src/mods/)

System Core API Serves
TimeSystem get() → TimeState {day, season, tickInDay}; advance(ticks) — the raw clock primitive (D23): moves tickInDay by ticks (firing day-start after each day rollover and season-change before it on a season boundary, in that order); no systems step, no time-tick; set(day, season, tickInDay = 0) — jump without events (D33: the optional tickInDay restores intra-day time from save snapshots); step(dt) (System interface, D23): advance(dt) then emits the time-tick bus event {day, season, tickInDay} (the post-advance state). step is driven by engine.tick/auto-tick — advance remains the backwards-compatible clock-only primitive time.*
GrowthSystem step(dt) — driven by the tick loop (D23): accumulates day-fraction ticks against the time def's dayLengthTicks (read from the registry in init); each full day advances every crop plot AND every animal (one pass, advanceDay). Crops (unchanged): a stage progresses only when daysInStage + 1 >= stage.days and the stage's waterRequired is false or the plot's watered is true; on advance daysInStage and watered reset. Animals (D23): a stage progresses only when daysInStage + 1 >= stage.days and the stage's feedRequired is false or the animal's fed is true; on advance daysInStage and fed reset; a mature (final-stage) animal emits animal-produce {animalId, animalDefId, stageIndex, stage} per eligible day and its fed resets (feeding is consumed daily). Emits crop-grow {plotId, x, y, cropItemId, stageIndex, stage} per crop advance and animal-grow {animalId, animalDefId, stageIndex, stage} per animal advance. forceStage(plotId, stageIndex) — debug mechanism behind crop.stage (validates plot/crop/index, jumps, resets daysInStage, emits crop-grow). Both forceStage and the day-boundary advancePlot set plot.spriteRef to the new stage's def spriteRef when present (D26). spawnAnimal(animalId, x, y) → AnimalEntity — requires a registered AnimalDef (throws growth: unknown animal def: <id>), non-negative integer coords (throws growth: x and y must be non-negative integers), creates {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), with spriteRef set from the def's stage-0 spriteRef when present. feed(animalId) → AnimalEntity — sets fed: true (throws growth: unknown animal: <id>); emits the animal-fed bus event {animalId, animalDefId} (bus-only — no hook). forceAnimalStage(animalEntityId, stageIndex) → AnimalEntity — debug mechanism behind animal.stage (validates entity/def/index, jumps, resets daysInStage/fed, emits animal-grow); sets animal.spriteRef to the new stage's def spriteRef when present (as does the day-boundary advanceAnimal). Bus events crop-grow/animal-grow/animal-produce map to hooks onCropGrow/onAnimalGrow/onAnimalProduce crop.stage, animal.*
PlantingSystem till(x,y) — creates/updates a plot entity {id: "plot:<x>:<y>", type: "plot", x, y, tilled: true, watered: false, cropItemId?, stageIndex?, daysInStage?, spriteRef?} (idempotent). sow(x,y,itemId) — requires a tilled plot with no crop, a registered seed item, and a crop whose seedItem matches; consumes 1 seed from player.inventory when an inventory field exists (emitting item-use), sets cropItemId to the crop def id (not the seed id), stageIndex: 0, and — when the crop's stage-0 def has a spriteRef — plot.spriteRef (D26: the plot's appearance follows the crop stage). water(x,y) — sets watered: true. harvest(x,y) → drops[] — requires the final stage; rolls drops per the def (chance omitted = always; a chance draw consumes one draw from the engine RNG via ctx.rng, D28), grants them to player.inventory (emitting item-acquire per drop), emits crop-harvest {plotId, x, y, cropItemId, drops}, then clears the crop fields and spriteRef (plot stays tilled). Invalid operations throw Error (MCP converts to error results). Bus event crop-harvest maps to hook onCropHarvest. crop.stage (plot resolution), crop.till / crop.sow / crop.water / crop.harvest (debug wrappers)
InventorySystem give(itemId, qty) → number (new count; emits item-acquire), take(itemId, qty) → number (clamps to available, returns actual taken; emits item-use), count(itemId) → number, items() → Record<string, number> (snapshot copy). Inventory lives on the player entity as player.inventory: { [itemId]: qty }, created in init if absent. give/take reject non-positive qty. Bus events item-acquire/item-use are mapped by engine.wireEvents to hooks onItemAcquire/onItemUse (fresh HookContext; mods read state via ctx.world). item.give, item.query_inventory
EconomySystem buy(shopId, itemId, qty) → {shopId, itemId, qty, total, gold} — requires the shop to have a sells line for the item (convention: sells = the shop sells to the player, player pays sells[].price); cost = price × qty; throws on unknown shop, item not sold, or gold < cost; on success deducts player.gold, grants items into player.inventory (emitting item-acquire), emits transaction {shopId, type: "buy", itemId, qty, total} (mapped by wireEvents to hook onTransaction). sell(shopId, itemId, qty) → {shopId, itemId, qty, total, gold} — requires a buys line (convention: buys = the shop buys from the player, player earns buys[].price); total = price × qty; throws on unknown shop, item not bought, or fewer than qty in inventory; on success removes items (emitting item-use), adds gold, emits transaction {shopId, type: "sell", itemId, qty, total}. price(shopId, itemId) → {shopId, itemId, buy, sell, stock} — buy = the price the shop pays the player (from buys), sell = the price the shop charges the player (from sells), stock = the sell line's optional stock cap (null when absent/infinite); unknown shop throws. Player money lives on the player entity as player.gold (flat field, initialized to STARTING_GOLD = 0 in init if absent/invalid — a documented constant, not content policy). stock is informational in this slice: no depletion, no restock logic. buy/sell reject non-positive qty. shop.buy / shop.sell / shop.price (planned)
TriggerSystem evaluate(condition, ctx) → boolean — pure recursive evaluation of a ConditionExpr against a TriggerEvalCtx ({ get(path): unknown }); eq/exists compare the resolved path value (eq strict ===; exists is resolved !== undefined), gte/lte require both operands to be numbers (non-numeric → false), and/or/not compose children. fire(triggerId) → {triggerId, fired} — looks up the trigger def in the behavior registry (throws trigger: unknown trigger: <id> when missing), evaluates when with a fresh ctx (no event payload, so payload.* paths resolve to undefined), and when true runs its actions and emits the trigger-fire bus event {triggerId} (mapped by wireEvents to hook onTriggerFire); when false it is a no-op returning fired: false. match(event, payload) → {fired} — runs ALL triggers whose condition evaluates true against a ctx built from the current world/time plus the event payload, executing their actions and emitting trigger-fire per fired trigger. on(event, payload) (System interface) delegates to match — the engine forwards every bus event to systems' on, so matching runs on any bus event except trigger-fire itself, and matching is not re-entrant (events emitted by trigger actions during an in-progress match/fire pass are not re-matched in the same pass) — these are the v1 loop guards. Path resolution convention: dotted path split on ., resolved against time.* → TimeSystem.get(); world.<entityId>.* → the entity by id (missing entity → undefined); player.* → shorthand for world.player.*; payload.* → the event payload (present only during match/on, absent during fire); anything else → undefined. Action execution: set-state (path must target a world entity: player.<field> or world.<entityId>.<field>; missing entity throws trigger action set-state: no entity: <id>) writes the field (dotted path, creating intermediate objects as needed); emit-event emits the named bus event with the optional payload; give-item grants qty of itemId directly into player.inventory and emits item-acquire {itemId, qty, total} (same direct field-manipulation pattern as EconomySystem); play-sound and dialogue are accepted no-ops in headless v1 (no runtime effect, reserved for the render/audio layers). Time is read from the registered TimeSystem via SystemInitCtx.systems (added in this slice). v2 (state machines, D20): the same match(event, payload) pass drives BOTH triggers and machines — triggers first (fires + emits trigger-fire per fired trigger), then every registered statemachine def runs matchMachine (same matching re-entrancy guard covers both; trigger-fire short-circuits the whole pass). machineStatus(machineId) → {machineId, state, started} (throws statemachine: unknown machine: <id>; started: false with state = declared initial when no instance entity exists yet) and machineTransition(machineId, to) → {machineId, from, to, transitioned} (debug force — validates the machine and the target state, then applyTransition regardless of declared transitions). Machine state persists on a world entity { id: "sm:<machineId>", type: "statemachine", machineId, state }, created lazily on first transition (never at boot — existing worlds/snapshots are unaffected); because it is a plain entity it is snapshot/restore-able by save.* and queryable via world.get("sm:<machineId>"). applyTransition(def, to) runs the leaving state's onExit actions, sets entity.state, runs the entering state's onEnter actions, then emits the state-change bus event {machineId, from, to} (mapped by wireEvents to hook onStateChange); a transition to the current state is a no-op (no actions, no event, transitioned: false). Transition matching: a transitions[] entry fires when transition.from equals the machine's current state, transition.on equals the current bus event name (omitted on = condition-only transition evaluated on every event), and transition.when (a ConditionExpr over the same ctx, including payload.*) evaluates true; first matching entry in declaration order wins, at most one transition per machine per event. Schedule entries (onSchedule[], the v2 time primitive): at is "day-start", "season-change", or {day?, season?} (at least one present) matched against the payload of those two TimeSystem boundary events; an entry fires when at matches the current event payload AND its optional when holds (first such entry wins); schedule entries apply from any state and are evaluated after transitions. Time-driven transitions therefore need no game loop beyond the tick loop (D23): at is now "day-start", "season-change", {day?, season?} (matched against the payload of those two TimeSystem boundary events), or { tickInDay: n, day?, season? } (matched against the time-tick bus event emitted by TimeSystem.step each tick — n ∈ [0, dayLengthTicks), plus optional day/season); an entry fires when at matches the current event AND its optional when holds (first such entry wins). The pre-tick-loop limitation ("intra-day timing awaits a tick loop") is resolved: at: {tickInDay} fires exactly when payload.tickInDay === n, bounded by the caller's step granularity (one time-tick per engine.tick(dt)). behavior.*
DialogueSystem start(dialogueId, ctx?) → DialogueState — starts a dialogue: looks up the def in the dialogue registry (throws dialogue: unknown dialogue: <id>), replaces any active dialogue (the old entity is removed WITHOUT emitting dialogue-end — a hard debug-driver reset), creates the active entity {id: "dialogue:active", type: "dialogue", dialogueId, nodeId, payload?}, emits dialogue-start {dialogueId, nodeId} (mapped by wireEvents to hook onDialogueStart), then enters the start node (runs its actions and computes its outbound path). `current() → DialogueState null— the active dialogue's node state (text + **available** choices), ornullwhen no dialogue is active or its def/node is no longer registered.choose(choiceIndex) → DialogueState— picks the choice atindexfrom the **available** list (throwsdialogue: no active dialogue/dialogue: choice index out of range: ), runs the choice'sactions, moves to itstonode, runs that node'sactions, and returns the new state.end()— force-removes the active entity (no event).evaluate(condition, ctx) → boolean— pure condition evaluation (sharedbehavior-runtimehelpers). **DialogueState** ={dialogueId, nodeId, text, choices: [{text, to}]}. **Node semantics**: achoicesnode presents its choices filtered bywhen(evaluated against the dialogue ctx —time.,player.,world.., andpayload.from thestartctx); a node with onlytopresents a single synthesizedContinuechoice (so the headless driver has one uniform advance action); a node with neither (terminal) — or a choices node whose conditions all fail — **ends** the dialogue on entry: the node's text is still returned,dialogue-end{dialogueId}is emitted (mapped to hookonDialogueEnd), and the entity is removed. Actions reuse the shared 5-kind action set (set-state/emit-event/give-item/play-sound/dialogue;play-sound/dialogueare no-ops in headless v1 — nested dialogue via thedialogue` action is reserved for a later slice).
WeatherSystem get() → WeatherState — current weather {weatherId, name?, season} (throws weather: no weather def registered when no def / weather: no weather state initialized otherwise). set(weatherId) → WeatherState — force a declared weather (throws weather: unknown weather id: <id>); emits weather-change {from, to} only on change (mapped by wireEvents to hook onWeatherChange); returns {weatherId, name?, season, from?} (from present only when it changed). roll() → WeatherState — weighted pick from the current season's probability table (season read from TimeSystem.get().season; falls back to the first season's table when the season is uncovered), using an injectable RNG (new WeatherSystem(rng?) — a constructor-injected WeatherRng wins for deterministic tests; otherwise the default resolves ctx.rng (the engine's seeded RandomSource, D28) when available, else Math.random); emits weather-change on change. `table(season) → WeatherEntry[] undefined— the season's weighted entries (copies) orundefined. Implements the Systemon(event)interface: onday-startit rolls (the engine forwards every bus event). State lives on a boot-created entity{id: "weather", type: "weather", weatherId}with the initial weather = the current season's first table entry (deterministic — no RNG at boot); the entity is created only when a weather def is registered, so games withoutcontent/weather.yaml` boot cleanly and the weather calls throw clear errors.
SaveSystem snapshot() → WorldSnapshot — serializes the current world as { format: "nengine-save", version: CURRENT_SAVE_VERSION (= 1), savedAt: ISO timestamp string, world: Entity[], rngState?: {seed, calls} }; entities are shallow copies (the live world is not aliased). rngState (D28): captured from ctx.rng.state() at snapshot time (present whenever the engine exposes an RNG) — the engine's determinism anchor. Covers world entities + the RNG anchor + TimeSystem state (D33): snapshot.time = TimeSystem.get() when a time system exists (optional additive field — old snapshots without time remain valid, CURRENT_SAVE_VERSION stays 1); restore calls time.set(day, season, tickInDay) when the snapshot carries time, leaving the current clock untouched when it does not. restore(snapshot) → { restored: number } — validates the snapshot shape (throws Error on malformed input), rejects future versions (version > CURRENT_SAVE_VERSION), migrates to the current version via registered migrations, then replaces ALL world entities: every current entity is removed via world.remove(id) and the snapshot's entities are re-created in order as fresh copies via world.create; restored = number of entities created. rngState restore (D28): when the snapshot carries rngState and the engine exposes an RNG, restore calls setSeed(rngState.seed) — the sequence re-anchors to the stored seed and calls resets to 0 (draw replay is NOT built — documented semantic). A snapshot without rngState restores the world and leaves the RNG untouched (migration-free, optional field). migrate(snapshot, fromVersion?, toVersion?) → WorldSnapshot — validates, then applies the migration registry in sequence for each version N in [fromVersion, toVersion); no-op (returns the same snapshot) when fromVersion === toVersion; throws on backwards migration; a version without a registered migration is an identity step (v1 has an empty registry, so any forward migration returns the snapshot unchanged after validation). `validate(snapshot) → { ok: true } { ok: false, errors: string[] }— structural validation against the save format:formatliteral"nengine-save", positive-integerversion, non-emptysavedAtstring,worldarray of entities each with non-emptyidandtype, and (when present)rngStatewith a numberseedand a non-negative integercalls.registerMigration(fromVersion, fn)` — the mechanism a content author/Mod uses to add a schema-version migration. Save policy (when to save, slots, autosave, UI) is creator content/Mods (D14) and is not part of this system.
PlacementSystem place(buildingId, x, y) → BuildingPlacement — validates in order: coords are non-negative integers; the building def exists (registry kind building, throws placement: unknown building: <id>); the footprint (def.footprint.w/h) fits within the map entity's width/height (throws placement: footprint out of bounds at (x,y)); every footprint cell's tile is buildable per the def's buildable rule (throws placement: tile (<cx>,<cy>) is not buildable for <id>); no existing building footprint overlaps (throws placement: footprint overlaps existing building <id>). Then creates a world entity {id: "building:<buildingId>:<x>:<y>", type: "building", buildingId, x, y, w, h, blocksMovement} (blocking copied from the def) plus spriteRef copied from the def when present (D26) and emits placement-change {type: "place", buildingId, x, y, w, h} (mapped by wireEvents to hook onPlacementChange). remove(x, y) → BuildingPlacement — removes the building whose footprint contains the cell (throws placement: no building at (x,y)), emits placement-change {type: "remove", ...}, returns the removed entity. list() → BuildingPlacement[] — all placed buildings. `occupies(x, y) → BuildingPlacement undefined— the building whose footprint contains the cell (used by MovementSystem for blocking). **Tile resolution**: the shared map-grid helper reads the map entity (type: "map"— the world holds one per loadedMapDef); the terrain layer is the layer named"ground", else the first layer; the tile id isterrainLayer.data[y][x]. **Buildability**:{tiles}requires the cell's tile id to be in the list;"map"(D33) requires every footprint cell to be walkable (isWalkableTile— the D33 walkability resolution: declaredmap.walkablevalue or theUNWALKABLE_TILESfallback);"any"skips the tile check. ("map"previously behaved like"any"` — D33 makes it the explicit terrain-authored form; existing content places nothing on water, so no behavior breaks.) v1 occupancy rule: buildings block buildings; the player and non-building entities are transient and do not occupy cells (NPC blocking is MovementSystem's concern).
MovementSystem moveTo(npcId, x, y) → NpcMoveEvent {npcId, from: {x,y}, to: {x,y}} — validation chain in order: (1) coords are non-negative integers; (2) the npc entity exists and is type: "npc" (throws movement: unknown npc: <id>); (3) the target is within map bounds (throws movement: target out of bounds: (x,y)); (4) the target tile is walkable (throws movement: tile (x,y) is not walkable); (5) the target cell is not blocked by a blocksMovement building (throws movement: blocked by building <id> at (x,y)). Then sets npc.x/npc.y and emits npc-move {npcId, from, to} (bus event only — no hook, see mcp.md §npc). teleport(npcId, x, y) → NpcMoveEvent — same shape but skips bounds/walkability/blocking (still requires a valid npc and non-negative coords); emits npc-move. findPath(npcId, to: {x,y}) → TilePoint[] \| null (D33) — pure A* grid query from the npc's current cell to to (4-neighbor, Manhattan, array-based open set — maps are ~16×16, no binary heap); returns the path INCLUDING start and goal or null (unreachable / out of bounds / goal not walkable or blocked); validates the npc (movement: unknown npc: <id>) and coords like moveTo; NEVER moves the npc (stepwise NPC movement is documented future work). Blocking = blocksMovement building footprint via PlacementSystem.occupies + other npc/player grid entities on the cell (the pathing npc excluded). Walkability (D33): the tile id is read from the map entity's terrain layer (same map-grid helper as PlacementSystem); the single source of truth is isWalkableTile(map, x, y) (map-grid) — a tileId declared in the map's optional walkable map uses its boolean, an undeclared tileId falls back to UNWALKABLE_TILES (exported; v1 = {"water"}). moveTo/teleport semantics are unchanged for existing content (water still blocked via the fallback). npc.*
IntentSystem handle(action, args) → IntentResult {action, tile, ok, error?, detail?} — the player intent primitive (D25), auto-booted (id "intent") after MovementSystem. One mechanism behind every player input channel: MCP player.intent, the WS {"type":"intent"} message, and connectLocal.intent all call this. Validation failures return ok:false results, never throws. move {tile:{x,y}} — validates (coords non-negative integers → player entity exists → map bounds → walkable tile via the shared isWalkableTile helper (D33) → not blocked by a blocksMovement building via PlacementSystem.occupies), then sets player.x/player.y; success detail moved to (x,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). Reuses the shared map-grid/placement helpers directly (NOT MovementSystem.moveTo — npc-move behavior and tests untouched, D25). interact {tile:{x,y}} — smart context action on a plot tile; the action ORDER is content-ized (D33): read at call time from the single-file interact content kind (content/interact.yaml; registry.get("interact", "default") else the first def of the kind), iterating its order array and executing the FIRST applicable action; without a def the built-in default [harvest, water, sow, till] applies. Applicability conditions stay engine mechanisms: (1) harvest — plot tilled + crop at final stage (detail harvested <cropId>, grants the drop); (2) water — plot tilled + planted + not watered (detail watered plot); (3) sow — plot tilled + empty + a seed in inventory, using the D35-selected seed (player.selectedSeed when it names an owned seed-kind item, else the FIRST seed-kind inventory item — first key whose item def kind === "seed", consumes 1, detail sowed <ItemDef.name>; a successful sow keeps player.selectedSeed, clearing is creator policy); (4) till — plot absent/un-tilled (creates the plot, detail tilled plot). Non-applicable actions in the list are skipped; if none apply the existing errors fall through (crop <cropId> is not ready to harvest for a planted watered immature crop, else no seed in inventory). D35 action set extension (the same player-intent event semantics, success AND failure, and the same result shape — note IntentResult.detail widened from string to unknown so the new actions can carry structured results): buy {shopId, itemId, qty?} / sell {shopId, itemId, qty?} delegate to EconomySystem.buy/sell (default qty 1) with the transaction {shopId, itemId, qty, total, gold} as detail; errors player.intent: <action> requires itemId / requires shopId / qty must be a positive integer plus the economy errors passed through. **`talk {npcId? tile?}** — resolves the NPC (by id, or the npc entity ON the given tile), reads itsdialogue?: stringentity field (missing →player.intent: npc has no dialogue), andDialogueSystem.starts it; detail = theDialogueState. Errors:player.intent: talk requires npcId or tile/player.intent: unknown npc: /player.intent: no npc at tile. **choose {index}** —DialogueSystem.choose(index)with the next node state as detail; errorsplayer.intent: choose requires a non-negative integer index/player.intent: no active dialogue/ passthroughdialogue: choice index out of range: . **select-seed {seedItemId?}** — setsplayer.selectedSeedto a registeredseed-kind item id (absent/empty clears), detail{selectedSeed: string
API Signature Serves
ModLoader load(dir) → Promise<ModStatus[]> — ES module per Mod, validate manifest, bind hooks (D6/D13); reload(dir, id?) → Promise<ModStatus[]> — in-process re-load of one Mod or all: unbinds that Mod's previously registered hook functions (per-mod unsubscriber records) and re-imports the entry with a cache-busting ?t=<timestamp>-<seq> query so changed .mjs files are re-read without duplicate bindings (D13: the authoritative code-Mod path remains a headless-core restart). loadFromSource(source, id, version?) (D36) — execute + bind a Mod from its ESM source string (bundle mode): dynamic-imports the code as a base64 data:text/javascript URL, the SAME mechanism in Node ≥ 20 and browsers with zero fs access. Validation and hook binding are identical to file loading (manifest {id, version} required from the mod's own default export — no version fallback; non-function hooks and unknown hooks reported as errors while still loading; import/manifest failures → loaded: false + errors, never throw; a failed status reports the passed version when provided, else 0.0.0). reloadFromSources(sources: ModSource[]) (D36) — re-load a set of source mods (id/version/code) with cache-busting (#t= fragment on the data URL — a query string would corrupt the base64 payload): unbinds all source records then re-imports each, so bundle-mode reload works without re-reading the bundle mod.list / mod.status / mod.reload
HookRegistry on(hook: HookName, fn: HookFn) — returns an unsubscriber; bound(hook) — whether any fn is registered for the hook; fire(hook, ctx) mod.status, mod.reload
HookContext curated read-mostly surface: world.query(...), emit(event, payload), time.get(), registry.get(kind, id); optional payload?: unknown — set ONLY by the onPlayerIntent wiring, which threads the transient intent result ({action, tile, ok, error?, detail?}) that Mods cannot otherwise read from world state (D29.2) —

Declared hook points (Tier 1, from architecture.md): onDayStart, onSeasonChange, onCropHarvest, onCropGrow, onItemAcquire, onItemUse, onTransaction, onTriggerFire. Tier 2 (v2): onStateChange (fired on every state machine transition via the state-change bus event; hook context is the standard curated HookContext — the transition payload is read from world state, e.g. ctx.world.get("sm:<machineId>")?.state). Tier 2 (dialogue + weather): onDialogueStart / onDialogueEnd (fired via the dialogue-start/dialogue-end bus events — the active node is read from ctx.world.get("dialogue:active")) and onWeatherChange (fired via the weather-change bus event — current weather read from ctx.world.get("weather")?.weatherId). Tier 2 (placement): onPlacementChange (fired via the placement-change bus event — placed/removed buildings read from ctx.world.query({ type: "building" })). Tier 2 (animals, D23): onAnimalGrow (fired via the animal-grow bus event on every animal stage advance) and onAnimalProduce (fired via the animal-produce bus event per eligible day while an animal is mature). Tier 3 (D29): onPlayerIntent (fired via the player-intent bus event for EVERY IntentSystem.handle call — success AND failure; the ONLY hook whose context carries payload, the intent result itself: {action, tile, ok, error?, detail?}). npc-move and animal-fed deliberately have NO hook (see mcp.md §npc/§animal — both bus events are behavior-language-matchable via on: "npc-move" / on: "animal-fed"); player-intent gained its hook in D29 by reversing D25.4 — Mods (hooks-only, D6/D13) cannot subscribe to raw bus events, and the intent payload is transient (not readable from world state), so the "Mod hook demand" arm of the tri-criterion now holds.

// A Mod sees ONLY this:
export default {
  manifest: { id: "my-mod", version: "0.1.0" },
  hooks: {
    onDayStart(ctx: HookContext) { /* ctx only */ },
  },
};

6. MCP Gateway (packages/mcp/src/)

API Signature Serves
createGateway() McpGateway — central gateway (transport-agnostic) all tools
gateway.register(namespace: McpNamespace) per-subsystem registration (contract in mcp.md) server.*
gateway.listTools() / callTool(name, args) tool surface all tools
gateway.listResources() / readResource(uri) resource surface (template URIs, read(params)) all resources
gateway.listPrompts() / getPrompt(name, args) prompt surface all prompts
gateway.namespaces() / status() subsystem registry + enabled state server.status
gateway.enable(name) / disable(name) runtime subsystem switch (D9) — behind server.enable_subsystem / server.disable_subsystem server.*
createEngineNamespaces(engine) factory: builds server.*/map.*/time.*/item.*/crop.*/animal.*/shop.*/save.*/behavior.*/dialogue.*/weather.*/placement.*/npc.*/player.*/mod.*/sprite.*/sound.*/rng.*/particle.*/shader.*/animation.* namespaces bound to a running engine —
createNengineMcpServer(engine) McpServer adapter (MCP SDK) wired to the gateway; stdioTransport() for apps/mcp-server transports

packages/core/src/mcp/gateway.ts holds only a McpGatewayHandle placeholder (part of the Engine facade); the real gateway lives in @nengine/mcp.

7. Schema Map (packages/core/src/schema/)

Schema Content file Internal type (zod)
item content/items/<id>.yaml ItemDef — { id, name, kind: tool\|seed\|material\|consumable\|decoration, stackable, price?, use? }; price optional non-negative int, use optional string (descriptive content only — no usage system in this slice)
crop content/crops/<id>.yaml CropDef — { id, seedItem, stages: [{name, days, waterRequired, spriteRef?}], seasons, drops }; waterRequired is a boolean (plot must be watered during that stage); spriteRef optional string (D26 — appearance hint); drops: [{itemId, qty, chance?}] — qty positive int, chance optional 0..1 (omitted = always)
map content/maps/<id>.yaml MapDef — { id, width, height, layers, spawn, tilesetRef, walkable?, tiles? } — walkable?: Record<tileId, boolean> optional (D33): false → not walkable, true → walkable, undeclared tileId → the UNWALKABLE_TILES fallback (default {"water"}) — the single source of truth behind isWalkableTile; tiles?: Record<tileId, assetId> optional (D31): maps a tile id to an image asset the viewport draws; absent/unmapped ids fall back to the palette
time content/time.yaml TimeDef — { dayLengthTicks, seasons: [{name, dayCount}], start }
shop content/shops/<id>.yaml ShopDef — { id, buys: [{itemId, price}], sells: [{itemId, price, stock?}] }; price positive int, stock optional positive int (absent = infinite; informational, no restock logic)
behavior content/behaviors/<id>.yaml TriggerDef — { id, when: ConditionExpr, fire: Action[] }; fire is 1+ actions (see grammar below)
statemachine content/statemachines/<id>.yaml StateMachineDef — { id, initial, states: [{id, onEnter?, onExit?}], transitions: [{from, to, when?, on?}], onSchedule?: [{at, to, when?}] }; see the StateMachineDef grammar below
dialogue content/dialogues/<id>.yaml DialogueDef — { id, startNode, nodes: [{id, text, choices?: [{text, to, when?, actions?}], to?, actions?}] }; see the DialogueDef grammar below
weather content/weather.yaml WeatherDef — { id, seasons: [{season, weathers: [{id, weight}]}], defaults?: {[weatherId]: {name}} }; single-file kind (like time) — see the WeatherDef grammar below
building content/buildings/<id>.yaml BuildingDef — { id, name?, footprint: {w, h}, buildable: "map"\|{tiles: string[]}\|"any", blocksMovement, description?, spriteRef?, shop? } — see the BuildingDef grammar below
animal content/animals/<id>.yaml AnimalDef — { id, stages: [{name, days, feedRequired, spriteRef?}], produces: [{itemId, qty, chance?}] } — see the AnimalDef grammar below
sprite content/sprites/<id>.yaml SpriteDef — { id, frames: [{name?, durationTicks?, layers: [{shape:"rect"\|"circle"\|"image", ...}]}] } (zod strict discriminated union); programmatic draw instructions plus the image layer (D31): {shape:"image", src: <assetId>, x?, y?, w?, h?} — src references a binary asset (content/assets/), x/y position the image's top-left in tile space (default 0,0), w/h size it in tile-space units (crop clamped to the source: smaller = crop from the source top-left, larger = upscale the whole image; absent = natural pixel size); see the SpriteDef grammar below
sound content/sounds/<id>.yaml SoundDef — { id, synth?: {wave:"sine"\|"square"\|"triangle"\|"sawtooth", freq, durationMs, gain?, slideTo?}, src? } (zod strict; at least one of synth/src required); src is an audio asset id (D31 — a file in content/assets/) the render player decodes and plays (src takes precedence over synth); see the SoundDef grammar below
interact content/interact.yaml InteractDef — { id, order: ["harvest"\|"water"\|"sow"\|"till", ...] } (zod strict; single-file kind like time/weather, D33) — the player interact action ORDER (content policy); applicability conditions stay engine mechanisms; no MCP tools (single-file-kind precedent)
particle content/particles/<id>.yaml ParticleDef — { id, emitRate?, lifetime?: {min, max}, speed?: {min, max}, angle?, spread?, gravity?, size?: {min, max}, colors?: ["#rrggbb",...], fade? } (zod strict; D34) — a pure-visual emission profile; every field 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). Inert presentation data: content triggers bursts via emit-event {event:"particle", payload:{particleId, x?, y?, entityId?}}, the render layer spawns/animates
shader content/shaders/<id>.yaml ShaderDef — { id, filter: "blur"\|"tint"\|"grayscale"\|"sepia"\|"hue"\|"brightness", params?: {[k]: number\|string} } (zod strict; D34) — a parameterized selection from the engine's BUILT-IN filter library (no GLSL in content); params drive the filter (blur {strength}, tint {color}/{r,g,b}, grayscale {factor}, sepia {}, hue {degrees}, brightness {factor}). Referenced from SpriteDef.filters and the weather visual mapping; unknown id → silently skipped
animation content/animations/<id>.yaml AnimationDef — { id, initial, states: [{id, spriteRef, fps?, loop?}], transitions?: [{from, to, on?}] } (zod strict; D38) — an animation state machine over sprite defs; each state's sprite def's frames animate at the state's fps (default DEFAULT_ANIMATION_FPS 10) with loop default true (false = play once then hold the last frame); see the AnimationDef grammar below
mod (not content — code Mods at mods/<id>/index.mjs) ModManifest — { id, version }, read inline from the Mod module's export default { manifest, hooks }; the loader does not read a content/mods/<id>/manifest.yaml nor any contentOverrides field
save (none — runtime state, D14) WorldSnapshot — { format: "nengine-save", version: number, savedAt: string, world: Entity[], rngState?: {seed: number, calls: number}, time?: {day: number, season: string, tickInDay: number} } (shape defined by worldSnapshotSchema; rngState optional — the engine RNG anchor, D28; time optional — the TimeSystem clock, D33); NOT a content kind — no content/saves/ dir, no contentSchemas entry

save deliberately has no content schema or content file: it serializes runtime world state (D14, mechanism only). See §4 SaveSystem.

Condition grammar (v1): a ConditionExpr is one of { op: "eq"|"gte"|"lte", path, value } (value is required and may be any JSON value), { op: "exists", path }, { and: [expr, ...] }, { or: [expr, ...] }, or { not: expr } — composed recursively. path is a dotted path resolved per the convention in §4 (time.day, time.season, world.<id>.<field>, player.<field>, payload.<key>). eq is strict equality, gte/lte require numeric operands, exists is resolved !== undefined. Actions (v1/v2): set-state {action, path, value} (absolute — D20), add {action, path, value} (D33 — incremental: numeric field += value, missing field starts at 0, non-numeric field throws, value must be finite; set-state stays absolute), emit-event {action, event, payload?}, give-item {action, itemId, qty}, play-sound {action, sound} (emits the sound-play bus event {soundId} — the engine's audio-event mechanism; the render layer plays in round 2, D26), dialogue {action, text} (no-op in headless v1).

StateMachineDef grammar (v2, D15/D20/D23): initial must be a declared state id; states[] (1+) with unique ids and optional onEnter/onExit action lists (same 5-kind action set); transitions[] entries {from, to, when?, on?} — from/to must be declared states and at least one of when/on is required (enforced by stateMachineSchema's superRefine). onSchedule[] entries {at, to, when?} — at is "day-start", "season-change", {day?, season?} (at least one of day/season), or {tickInDay, day?, season?} (at least one of day/season/tickInDay; the tickInDay form matches the time-tick event, D23); to must be a declared state. Evaluation semantics: see §4 TriggerSystem (v2). State machine instances are world entities (sm:<machineId>, type statemachine) — snapshot-able, MCP-inspectable, with quest progress conventionally written to player.questProgress.<questId>.

DialogueDef grammar (D21): startNode must be a declared node id; nodes[] (1+) with unique ids and exactly one of choices or to (or neither — a terminal node), each node optionally carrying actions (same 5-kind action set, run on node enter). choices[] entries {text, to, when?, actions?} — to must be a declared node, when is a v1 ConditionExpr evaluated against the dialogue ctx, actions run when the choice is picked. Runtime semantics: see §4 DialogueSystem. Active dialogue state is a world entity (dialogue:active, type dialogue) — snapshot-able, MCP-inspectable.

WeatherDef grammar (D21): seasons[] (1+) each {season, weathers: [{id, weight}]} — weight a positive integer (relative probability); season names match time.yaml. defaults? maps declared weather ids to {name} display metadata (keys must be declared weather ids). Runtime semantics: see §4 WeatherSystem. Weather state is a world entity (weather, type weather) created at boot when the def exists.

BuildingDef grammar (D22): footprint {w, h} — positive-integer grid size; placement anchors the axis-aligned rectangle at the placement origin (x, y). buildable is one of "map" (any tile id on the map is buildable), {tiles: string[]} (non-empty; every footprint cell's tile id must be in the list), or "any" (tile check skipped). "map" and "any" behave identically in v1 ("map" is the explicit terrain-authored form). blocksMovement — whether a placed instance blocks NPC movement (consumed by MovementSystem). spriteRef — optional string (D26; copied onto placed instances; missing = renderer falls back to shape drawing). shop — optional string (D35): a registered shop def id the building is a front for; copied onto placed instances (building.shop) so UIs discover shops from world entities. Placed instances are world entities {id: "building:<buildingId>:<x>:<y>", type: "building", buildingId, x, y, w, h, blocksMovement, spriteRef?, shop?} — snapshot-able by save.*, inspectable by MCP. Runtime semantics: see §4 PlacementSystem.

AnimalDef grammar (D19/D23): stages[] (1+) each { name, days, feedRequired, spriteRef? } — days positive int, feedRequired boolean (mirrors the crop stage model with feedRequired in place of waterRequired); spriteRef optional string (D26 — appearance hint; missing = renderer falls back to shape drawing); produces[] (1+) each { itemId, qty, chance? } (mirrors crop drops — qty positive int, chance optional 0..1). The def is the growth cycle + produce table (data only); feed/produce/harvest RULES are behavior-language content hooking the animal-grow/animal-produce/animal-fed bus events — the engine grants nothing. Animal instances are world entities {id: "animal:<animalId>:<n>", type: "animal", animalId, x, y, stageIndex?, daysInStage?, fed?, spriteRef?} — snapshot-able by save.*, inspectable by MCP. Runtime semantics: see §4 GrowthSystem.

SpriteDef grammar (D26/D31/D34): frames[] (1+) each { name?, durationTicks?, layers } — name optional display label, durationTicks optional non-negative int (frame play time in engine ticks; a single-frame sprite is static), layers is an ordered paint list (1+, drawn back-to-front) of { shape: "rect"|"circle"|"image", ... } (zod strict — unknown keys rejected; shape is the union discriminator). Optional top-level filters?: string[] (D34) — shader ids applied to the drawn frames (unknown id → silently skipped). rect layers use x/y (non-negative ints, top-left in tile space) + w/h (positive ints) + required fill matching "#rrggbb" (schema-enforced); circle layers use x/y + r (positive int) + fill. image layers (D31/D34): { shape: "image", src, x?, y?, w?, h?, frameX?, frameY?, frameW?, frameH? } — src is a required non-empty asset id (a file in content/assets/ — the loader resolves it at render time; a missing asset degrades to the shape/color fallback); x/y optional non-negative ints position the image's top-left in tile space (default 0, 0 — same convention as rect); w/h optional positive ints are the image's size in tile-space units, with the crop clamped to the source image (a w/h smaller than the asset's pixels crops that many from the source top-left; a larger one upscales the whole image); absent → the whole image at its natural pixel size (DEFAULT_IMAGE_SIZE fallback when the asset dims are unknown). Frame-crop fields (D34) add sprite-sheet support: frameX/frameY/frameW/frameH are an optional source-pixel crop rectangle into the asset image (default = whole image, preserving D31 behavior); a sprite-sheet is one asset file with multiple sprite defs each cropping a different frame rect. The renderer builds a sub-texture for the frame rect (cached by asset + rect). Sprites are inert presentation data — no system consumes them; the WS snapshot frame carries the full sprites catalog so render viewports can draw on demand (D26).

SoundDef grammar (D26/D31): synth? is the v1 synthesis spec { wave, freq, durationMs, gain?, slideTo? } (zod strict; wave one of sine|square|triangle|sawtooth, freq positive Hz, durationMs positive, gain optional 0..1, slideTo optional positive end-frequency sweep). src? is an optional audio asset id (D31) referencing a file in content/assets/ — the render player decodes it (base64 → decodeAudioData) and plays it; src takes precedence over synth at play time (a src whose asset is missing or undecodable is silently skipped). At least one of synth/src is required (schema .refine). Sounds are inert data; the behavior-language play-sound action emits the sound-play bus event {soundId} (the engine mechanism — render layer plays) and the snapshot frame carries the full sounds catalog.

AnimationDef grammar (D38): initial must be a declared state id; states[] (1+) with unique ids, each { id, spriteRef, fps?, loop? } — spriteRef a registered sprite def id, fps optional positive number (the state's frame rate; default DEFAULT_ANIMATION_FPS 10), loop optional boolean (default true; false plays the state once then holds its last frame). transitions[] optional {from, to, on?} — an authoring aid only (the runtime does NOT enforce hard transitions; content can jump between states freely — D38). Cross-field checks via animationSchema's superRefine (mirroring the statemachine kind): duplicate state ids rejected, initial must be declared, transition from/to must be declared. State resolution (render layer): an entity whose spriteRef matches the animation def's id uses it; the state is (1) the entity's explicit anim?: string field when it names a declared state (the behavior language sets it via set-state player.anim "dance" and clears it via set-state player.anim null — null is a supported clear, set-state's value schema already accepts null, no language change), else (2) motion inference — "walk" while the entity is tweening between tiles, "idle" when still, else (3) initial. No matching animation def → today's rendering unchanged (additive; no fallback regression). Animation defs are inert presentation data — no system consumes them; the WS snapshot frame carries the full animations catalog.

8. Render Layer (packages/render/, D26 round 2)

The browser-side presentation half of the D26 pipeline: it consumes the engine's data (sprites/sounds/particles/shaders/animations catalogs, entity spriteRef/anim/label fields) and events (sound-play, particle, input) and never hardcodes art. Node-unit-testable pure modules (sprite-draw.ts, sound.ts, keys.ts, particles.ts, filters.ts, weather.ts, labels.ts, tween.ts (D38), plus the existing shapes.ts/tiles.ts) hold the logic; viewport.ts and sync/* are the browser seams. This is render-layer freedom (D1/D8/D34) — the engine owns presentation data, the renderer owns drawing/playback.

API Signature Notes
createViewport(container, opts) ({map?, tileSize?, assetsBase?}) → Promise<ViewportHandle> PixiJS bootstrap; render(snapshot?), onEvent({event, payload}), tileAt(clientX, clientY), resize(), destroy(). RenderSnapshot = { map, world, sprites?, sounds?, particles?, shaders?, shops?, animations?, assets? } (catalogs optional — absent = shape-only; shops (D35) is the shop-def catalog the dev UI's shop panel reads in ws mode; animations? (D38) is the animation state machine catalog). assetsBase? (D37): the base URL for split-assets fetches — image entries with file (no inline data) are fetched from assetsBase/file (browser fetch → blob → texture); inline entries decode from data: URLs. Smooth movement (D38, render-layer only): the viewport tracks per-entity last-known tile coords; a consecutive coordinate change (player/npc/animal entities) starts a movement tween over MOVE_DURATION_MS (250 ms, easeOutQuad) that interpolates the entity's drawn position from the previous tile to the new one during the rAF loop — the engine stays authoritative instant grid-jumps (moveTo/IntentSystem.move untouched; multi-viewport consistent). During the tween the motion state is "walk" (settled → "idle"), driving the animation state (below); buildings/plots/static entities are never tweened. Animation state machines (D38): an entity whose spriteRef matches a snapshot animations def id renders through that def — state = explicit entity.anim field (when it names a declared state) else motion inference (walk/idle) else initial; the state's spriteRef sprite draws at the state's fps (default 10) with loop default true (false holds the last frame). No matching animation def → the existing resolveSprite/shape path unchanged (no regression). Draws ground/spawn from the map def — ground tiles render the map.tiles image asset when mapped AND its texture is loaded, else tileColor(id) — then per entity: spriteRef present and resolvable in the snapshot's sprites catalog → draw the sprite (plot entities keep their shape base rects — tilled/watered — with the sprite layered on top; all other sprite entities draw the sprite instead of the shape fallback); missing spriteRef OR unknown sprite id → the existing shapes.ts shape fallback, unchanged. Image layers (D31/D37): spriteDrawCommands emits image commands; the viewport loads every image asset in the snapshot's assets table as a PixiJS texture (async load, redraw when each pending load resolves — so late-arriving on-demand WS assets and split-assets fetches re-render through the same redraw-on-ready path; textures + pixel dims cached by asset id), draws each image command as a Sprite (sub-texture frame rect = the source crop; frame-crop rects (D34) — frameX/Y/W/H — take precedence over sourceW/H and produce a sub-texture cached keyed by asset + rect; absent crop = whole texture), and falls back to the shape/color path when the texture is missing. Filters (D34): sprite entities draw into a per-entity child container with the resolved SpriteDef.filters applied (cached Filter instances per shader id; unknown id → skipped silently). Labels (D34): entities with a non-empty label field draw a Pixi Text (12 px Arial, white + black stroke) anchored above the sprite (labelPosition). Particles (D34): onEvent spawns a burst on particle events (payload {particleId, x?, y?, entityId?} — tile coords or entity-resolved); a ParticleLayer Graphics steps + redraws particles each rAF tick. Weather visuals (D34): the weather entity ({type:"weather", weatherId}) in the snapshot world maps through weatherEffects — filter applied to the whole map container, periodic particle bursts at random map tiles, storm overlay color; weather change clears the previous visuals. Missing images/filters/particles degrade silently — no hard failure.
resolveSprite(entity, sprites) pure SpriteDef \| undefined sprite-ref → catalog lookup
frameAtTime(sprite, elapsedMs, fps?) pure number v1 animation clock: each frame occupies weight/total of the cycle where weight = durationTicks ?? 1; cycle length = total / fps seconds at the documented default 10 fps (DEFAULT_ANIMATION_FPS); single-frame sprites are always index 0. spriteDurationMs(sprite, fps?) = one full cycle. frameAtTimeOrHold(sprite, elapsedMs, fps?, loop?) (D38): same clock honoring an animation state's loop — loop: false cycles once then holds the last frame (one-shot states like "dance" freeze).
tweenPosition(from, to, elapsedMs, durationMs, tileSize) pure {xPx, yPx} D38 smooth-movement math (render-layer only): easeOutQuad interpolation from from tile to to tile at elapsedMs over durationMs, scaled to pixels by tileSize (clamped 0..1; zero/expired duration → the end tile). tweenTile(from, to, elapsedMs, durationMs) is the fractional-tile form the viewport feeds to sprite drawing; MOVE_DURATION_MS (250) is the documented default. motionState(from, to, moved) → "walk" when moving across changed coords else "idle"; animState(entity, prevX, prevY, motion) → explicit non-empty entity.anim else the motion inference; resolveAnimationState(def, stateName) clamps a state name to a declared state (falls back to initial); animationState(def, stateId) looks up a declared state.
spriteDrawCommands(sprite, frameIndex, entity, tileSize, assets?) pure SpriteDrawCommand[] tile-space layers → absolute pixel commands. Building footprint scaling: an entity carrying w/h (tile footprint) scales the sprite's tile-space box (0..32) to w×h tiles — a 3×3 coop sprite renders across its 3×3 footprint; everything else draws at 1×1 tile. circle layers with a non-uniform footprint become ellipses. #rrggbb → 0xrrggbb via hexToRgbColor. Image layers (D31/D34): {kind:"image", assetId, x, y, w, h, sourceW?, sourceH?, frameX?, frameY?, frameW?, frameH?} — dest box footprint-scaled, sourceW/sourceH the crop pixel count from the source top-left (absent = whole texture); frameX/Y/W/H the D34 sprite-sheet frame rect (carried through, clamped to the remaining source pixels when dims are known); the optional assets arg (ReadonlyMap<assetId, {width, height}>) resolves default dest sizes from pixel dimensions (DEFAULT_IMAGE_SIZE fallback).
particleSpawn(def, xPx, yPx, random?) pure ParticleState[] D34 burst spawn: emitRate particles at a world-space pixel position with per-particle velocity/size/color/lifetime derived from the def (documented defaults: emitRate 10, lifetime 500–1000 ms, speed 10–30 px/s, angle 0°, spread 360°, gravity 0 px/s², size 2–4 px, #ffffff, fade true). particleTick(states, dtMs) integrates gravity/velocity and returns survivors; particleRadius(state) is the current draw radius (a fading particle shrinks toward 25% over its lifetime — the documented single-Graphics fade approximation). resolveParticleDef(def) fills defaults. Pure-visual: uses Math.random (not the D28 deterministic engine RNG).
filterParams(def) pure FilterParams \| undefined D34 shader-def → numeric/color filter parameters: blur {kind:"blur", strength}, tint {kind:"tint", color:"#rrggbb"} (from color or r/g/b), grayscale {kind:"grayscale", factor}, sepia {kind:"sepia"}, hue {kind:"hue", degrees}, brightness {kind:"brightness", factor}; undefined for a malformed def. The viewport's thin makeFilter instantiates the concrete PixiJS BlurFilter/ColorMatrixFilter (cached per shader id).
weatherEffects(weatherId) pure WeatherEffects D34 built-in weather-id → visual mapping ({filterId?, particleId?, color?}): rain → {particleId:"rain", filterId:"rain-tint"}, wind → {particleId:"wind"}, storm → {particleId:"rain", filterId:"rain-tint", color:"#2f3b4c"}, sunny/snow/unknown → {}. Engine-provided presentation mechanism (not content).
labelPosition(entity, tileSize) pure LabelPosition D34 px anchor for an entity's label: tile-center x, LABEL_ABOVE_OFFSET above the tile top.
createSoundPlayer(factory?, assetsBase?) SoundPlayer Web Audio playback: setCatalog(SoundDef[]), setAssets(Record<assetId, AssetEntry>), onEvent(frame) (plays on sound-play {soundId}), prime(), destroy(). assetsBase? (D37): split-assets audio entries with file (no inline data) are fetched from assetsBase/file → decodeAudioData; inline entries decode base64. AudioContext autoplay policy: the context is created lazily on the first play attempt (or via prime() from a user gesture) and resumed if suspended; no context available / unknown sound id / no playable path → silently skipped; playback errors never crash the viewport. soundToAudioParams(def) is the pure def→Web-Audio-parameter mapping (wave/freq/durationMs/gain default 0.3/slideTo); soundSrcAssetId(def) is the pure src-asset-id mapping. Src playback (D31/D37): a def with src decodes the OGG (decodeAudioData — base64 in place or fetched from assetsBase) and plays it through an AudioBufferSourceNode — decoded buffers cached by asset id, async decode, src takes precedence over synth, missing/undecodable/unfetchable asset → silent skip; the synth path is unchanged.
connectWs({url, viewport, onEvent?, onSnapshot?, sound?}) WsSyncHandle Adds input(input) (sends {"type":"input", input}), the sound player (catalog fed from every snapshot, sound-play frames forwarded on receipt), and particle forwarding (particle frames → viewport.onEvent). WsSnapshotFrame includes sprites: SpriteDef[] / sounds: SoundDef[] / particles? / shaders? / shops?: ShopDef[] / animations?: AnimationDef[] (D38) and assetsIndex?: Record<assetId, {kind, mime, size}> (D37 — metadata only, no bytes). On-demand assets (D37): on each snapshot the client derives the wanted ids from the snapshot's presentation (snapshotAssetRefs — map tiles + sprite image layers + sound.src), feeds them to an AssetCoordinator (pure node-testable: wanted/loaded/pending dedupe, ≤64-id batches), sends {"type":"assets-request", ids}, and on each {type:"assets"} frame feeds the merged table to the viewport (re-render with the arriving assets — the existing redraw-on-ready path) and sound.setAssets.
connectLocal(engine, viewport, {sound?}) LocalSyncHandle Adds input(input) — client-side arrow mapping via arrowDirection (one tile from the player's current position) → intent("move", ...); sound player fed from the local registry + engine.assets and the engine bus (engine.events.on("sound-play")); particle events forwarded to viewport.onEvent. Snapshot now carries the sprite/sound/particle/shader/shop/animation (D38) catalogs and the asset table (engine.assets — inline base64 entries in fs mode, metadata-only {kind, mime, size, file?} entries in split-bundle mode, which the viewport/sound fetch via their assetsBase, D37).
arrowDirection(input) pure {dx,dy} \| undefined the shared "key:Arrow*" → adjacent-tile delta (server adapter and local handle both map the same way; D26).
tileAssetId(map, tileId) pure string \| undefined the map.tiles mapping lookup (D31): the image asset id a tile should render from, or undefined (palette fallback).

Both sync handles are now uniform: setTimeLabel / refresh / intent / input / destroy. apps/dev wires a keydown listener (arrow keys → sync.input("key:Arrow<…>"), all other keys ignored) and pointerdown/keydown call sound.prime() (gesture-gated AudioContext).

9. Packaging CLI (apps/pack, D30/D32/D36)

The offline build-time tool that turns a content directory into a self-contained static site. Not an MCP namespace (build-time capability, not runtime — D30). Since D32 it can also write a runnable Electron desktop app (--desktop); since D36 it can package Mods (--mods) and generate an installer config (--installer); since D37 it can split asset bytes out of bundle.json (--split-assets).

API Signature Notes
parsePackArgs({argv?, cwd?}) PackArgs \| null CLI arg parsing (--content required, --out default ./dist-pack, --mods <dir> (D36 — Mods dir, default none), --split-assets (D37 — boolean, default inline), --desktop boolean, --installer boolean (D36 — implies --desktop), --name <name> product name, --help → null); throws on a missing --content
compileBundle(contentDir, modsDir?, splitAssets?) Promise<ContentBundle> fs-loads + validates the dir via ContentLoader.load (per-item zod; throws with per-kind/per-id messages on any error, exit 1), then shapes {format: "nengine-bundle", version: 1, content, assets?, assetsIndex?, mods?} (non-empty kinds only; assets = the D31 asset table from content/assets/, base64-inlined; assetsIndex (D37) = the metadata index, always computed — {kind, mime, size, file: "nengine-assets/<id>.<ext>"}). With modsDir (D36): scans it with the ModLoader conventions — each subdir with an index.mjs/index.js entry, manifest from the module's default export {manifest: {id, version}}, entry source read as TEXT → bundle.mods[modId] = {manifest, code}; a Mod with no entry or no valid manifest fails the pack loudly (per-mod messages, mirrors content compile failures); a missing modsDir throws. With splitAssets: true (D37): returns the D37 split form via splitBundleAssets — assets removed, metadata-only assetsIndex (file paths included; no base64 in the bundle JSON)
packGame({contentDir, outDir, desktop?, installer?, modsDir?, splitAssets?, name?}) Promise<PackResult> compileBundle → programmatic vite build of the apps/dev app (reusing its ?bundle= mode, baked __NENGINE_BUNDLE_DEFAULT__ = "../bundle.json" + __NENGINE_ASSETS_BASE__ = "./nengine-assets" (D37), base: "./") → writes bundle.json into the out dir; with splitAssets: true (D37) it first writes each asset as a sibling out/nengine-assets/<id>.<ext> file (ext from MIME, bytes from the inline compile) and writes the metadata-only split bundle; with desktop: true, also writes the Electron shell into <out>/desktop/ (PackResult.desktopDir set, PackResult.modCount = packaged Mod count). With installer: true, generateDesktop is called with installer: true (electron-builder config + site copy incl. nengine-assets/ when present, see below)
generateDesktop(outDir, {productName?, installer?}) Promise<DesktopResult> (D32, apps/pack/src/desktop.ts) writes outDir/desktop/: main.cjs (HTTP server + BrowserWindow), package.json (npm-safe name from the product name, main, scripts.start = "electron .", devDependencies.electron ^ major — the runner installs it), README-desktop.md. Default product name "Farm Game". With installer: true (D36): additionally copies the site files (index.html + assets/ + bundle.json) into the desktop dir (so electron-builder can package them — main.cjs resolves the site as .., which IS the app dir once packaged), writes electron-builder.yml (appId: com.nengine.<npm-name>, productName, directories.output: dist, files: [main.cjs, package.json, index.html, assets/**, bundle.json], linux: {target: [AppImage], category: Game}, win: {target: [nsis]}, nsis: {oneClick: true} — Linux verified, Windows documented config), and extends package.json with devDependencies.electron-builder ^26 + scripts.dist = "electron-builder --linux" + scripts["dist:win"] = "electron-builder --win". DesktopResult.installerFile set
USAGE / DEFAULT_OUT_DIR constants help text / ./dist-pack

CLI: nengine-pack --content <dir> [--out <dir>] [--mods <dir>] [--split-assets] [--desktop] [--installer] [--name <name>] (binary nengine-pack → apps/pack/dist/index.js, auto-runs only on direct execution so tests drive it in-process — the apps/mcp-server entry pattern). Packaged output layout: out/index.html + out/assets/ (the built dev app, static-serveable) + out/bundle.json (the content + Mod sources) + — with --split-assets (D37) — out/nengine-assets/<id>.<ext> (one file per asset, referenced by bundle.assetsIndex[].file; bundle.json carries no base64) — plus, with --desktop, out/desktop/ (main.cjs + package.json + README-desktop.md). Opening the served index.html with no query params plays the game via createEngine({bundle}) + LocalSync — in split mode the viewport/sound fetch asset bytes from the sibling nengine-assets/ dir (the baked __NENGINE_ASSETS_BASE__, D37). The desktop shell (cd out/desktop && npm i && npm start) starts a local HTTP server (127.0.0.1, ephemeral port; GET-only, path-traversal-guarded, exact files only) and opens the same page in an Electron BrowserWindow (960×640, contextIsolation: true, nodeIntegration: false, title = product name; works from any cwd — SITE_ROOT = resolve(__dirname, "..")). Mods packaged with --mods boot in the browser AND the desktop shell — the engine's bundle mode loads bundle.mods via ModLoader.loadFromSource (D36); with --installer, cd out/desktop && npm run dist builds a Linux AppImage (verified end-to-end in this repo; the Windows NSIS target is documented config, not buildable here). A sidecar headless core (server-side Mods + multiplayer) remains a documented future path.

Change Log

Date Change Files
2026-08-13 Animation state machines + smooth movement (D38) — MCP surface now 116 tools / 34 resources / 15 prompts / 21 namespaces. §7: new content kind animation (content/animations/<id>.yaml, zod strict — {id, initial, states: [{id, spriteRef, fps?, loop?}], transitions?: [{from, to, on?}]}, superRefine cross-checks mirroring the statemachine kind: duplicate state ids rejected, initial must be declared, transition from/to must be declared). An animation def is a state machine over sprite defs — each state's sprite frames animate at the state's fps (default DEFAULT_ANIMATION_FPS 10); loop: false plays once then holds the last frame; transitions is an optional authoring aid the runtime does NOT enforce (D38). §1/§4: the state machine is driven by the entity's plain anim?: string field — the behavior language already sets/clears it (set-state player.anim "dance" / set-state player.anim null; null is a supported clear, no language change), with render-layer motion inference fallback. §8: RenderSnapshot/WsSnapshotFrame gain additive animations?: AnimationDef[] (connectLocal reads engine.registry); new pure tween.ts (tweenPosition/tweenTile easeOutQuad interpolation, motionState/animState motion inference, resolveAnimationState/animationState state resolution, MOVE_DURATION_MS = 250) + sprite-draw.ts gains frameAtTimeOrHold (loop-aware clock). The viewport tracks per-entity last-known tile coords and tweens consecutive coordinate changes (player/npc/animal) over MOVE_DURATION_MS in the rAF loop — render-layer only, the engine stays authoritative instant grid-jumps (moveTo/IntentSystem.move untouched, multi-viewport consistent, no protocol changes); an entity whose spriteRef matches an animation def id renders through that def (state = explicit anim → motion inference → initial, at the state's fps/loop), otherwise the existing resolveSprite/shape path is unchanged (no regression). Buildings/plots/static entities are never tweened. Sample game: content/animations/player.yaml (idle→player, walk→player-walk fps 8) + the new 2-frame placeholder content/sprites/player-walk.yaml. See decisions.md D38. api.md, mcp.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 Scalability fixes (D37): WS snapshot assets on-demand + --split-assets bundle splitting. NO MCP surface change (20/111/32/14 held). WS protocol (transport, §8): the snapshot frame's assets field (D31 — full base64 in EVERY snapshot) is replaced by a metadata-only assetsIndex (Record<assetId, {kind, mime, size}>); new client→server {"type":"assets-request","ids"} (bounded — client coordinator batches ≤64, server truncates over the cap) answered per-client with {"type":"assets","assets":Record<assetId, AssetEntry>} (requested ids only, unknown ids ignored). connectWs derives wanted ids from snapshot presentation (snapshotAssetRefs — map tiles + sprite image layers + sound.src), dedupes via the pure AssetCoordinator, and feeds arrivals into viewport/sound ({type:"assets"} → merged table → viewport re-render + setAssets). Bundle splitting (§2/§9): AssetEntry.data is now OPTIONAL (the engine asset table is a metadata index — inline/fs entries carry data; split entries carry size+file); new AssetIndexEntry + assetIndexEntrySchema + helpers assetByteSize/assetFileExtension; ContentBundle gains additive assetsIndex?: Record<assetId, {kind, mime, size, file?}> (carried by BOTH inline and split bundles — the index invariant); loadBundle builds the registry table from assets and/or assetsIndex (inline wins, augmented with index size/file). farm-pack gains --split-assets (default inline unchanged): packGame writes out/farm-assets/<id>.<ext> (one file per asset) and a metadata-only bundle.json (no base64); compileBundle(dir, modsDir?, splitAssets?) + splitBundleAssets produce the split form; buildViteSite bakes __FARM_ASSETS_BASE__ = "./farm-assets"; the dev app resolves the base from the baked define or the fetched bundle's dir; createViewport({assetsBase?})/createSoundPlayer(factory?, assetsBase?) fetch split entries via the new pure resolveAssetUrl. generateDesktop installer copies farm-assets/ when present. Root script pnpm pack:example:split. 692 tests green (was 670; +22: ws-broadcast 4, render asset-coordinator/refs/url 11, pack 5, core bundle 2). Browser E2E of split-mode rendering is not committed (prior-rounds pattern) — covered by unit + pack-output tests + documented. api.md, mcp.md, decisions.md (D37), AGENTS.md, packages/core (content/bundle.ts, content/loader.ts, index.ts), packages/render (sync/ws.ts, sync/asset-coordinator.ts, sync/asset-refs.ts, asset-url.ts, viewport.ts, sound.ts, index.ts), apps/mcp-server (ws-broadcast.ts + tests), apps/pack (src/index.ts, src/desktop.ts, test/pack.test.ts), apps/dev (src/main.ts, src/bundle-default.d.ts), package.json, examples/sample-game/README.md
2026-08-13 Mod packaging + browser Mod execution + installer generation (D36) — NO MCP surface count change (111/32/14/20 held). §2: ContentBundle gains additive mods?: Record<modId, {manifest: {id, version}, code}> (+bundledModSchema; BUNDLE_VERSION stays 1 — old bundles load unchanged). §5: ModLoader gains loadFromSource(source, id, version?) — executes ESM source via a base64 data:text/javascript dynamic import (Node ≥ 20 + browsers, zero fs), with validation/hook-binding identical to file loading (manifest required from the mod's own export, no version fallback; failures → loaded: false + errors, never throw) — plus reloadFromSources(ModSource[]) (cache-busted #t= fragment reload — a query string would corrupt the base64 payload), and shared entryFileOf/readModEntry pack-scan helpers. ModRecord generalizes to hold dir OR source; reload(dir, id) errors on source mods. §1: engine.start() in bundle mode loads bundle.mods via loadFromSource when no modsDir is configured (modsDir wins when both); engine.reloadMods() in bundle mode reloads all bundled sources from the stored bundle (id passed → throws). §9: farm-pack gains --mods <dir> (pack Mods into bundle.mods — ModLoader-convention scan, per-mod loud failures) and --installer (implies --desktop; generateDesktop({installer: true}) copies the site files into desktop/, writes electron-builder.yml — appId: com.farm.<slug>, files: [main.cjs, package.json, index.html, assets/**, bundle.json], linux: {target: [AppImage], category: Game} + documented win: {target: [nsis]} — and extends package.json with electron-builder ^26 + dist/dist:win scripts). Root script pnpm pack:example:full exercises all flags. Real AppImage build verified end-to-end (Pinefield Valley-0.1.0.AppImage produced; this environment needs NODE_OPTIONS=--use-system-ca for the AppImage-runtime download). Tests: core mods.test.ts +5 (loadFromSource valid/broken/no-manifest/bad-hooks/reload) +3 (bundle-mode engine boots bundled mods, bundle-mode reloadMods, modsDir precedence), bundle.test.ts +1 (mods schema additive + malformed rejected), apps/pack/test/pack.test.ts +7 (--mods/--installer args, mods packaging, no-mods zero regression, mods-pack failure, installer electron-builder.yml + dist scripts + site copy, packGame --mods integration). api.md, mcp.md, decisions.md (D36), AGENTS.md, packages/core (content/bundle.ts, mods/loader.ts, engine.ts, index.ts, mods/index.ts, content/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) — NO MCP surface count change (111/32/14/20 held). §4: IntentSystem.handle extends to seven actions. buy/sell delegate to EconomySystem ({shopId, itemId, qty?} default qty 1; transaction {shopId, itemId, qty, total, gold} as IntentResult.detail — which widened from string to unknown); `talk {npcId? tile?}starts an NPC's dialogue from the NPC entity'sdialoguefield;choose {index}advances the active dialogue (DialogueStateas detail);select-seed {seedItemId?}sets/clearsplayer.selectedSeed(a plain entity field). Theinteractsow case now usesplayer.selectedSeedwhen it names an owned seed, else falls back to the first inventory seed (a successful sow keeps the selection — clearing is creator policy). **§6**:createEngineNamespacesunchanged;player.intent's schema extends (action enum +itemId/qty/shopId/npcId/index/seedItemIdargs). **§7**:BuildingDefgains optionalshop?: string(a shop def id the building is a front for);PlacementSystem.placecopies it onto the building entity (building.shop). **§8**:RenderSnapshot/WsSnapshotFramegain additiveshops?: ShopDef[];connectLocal/connectWscarry it. **§9 (new §10? no — apps/dev)**: the dev app gains DOM panels (seed picker, inventory bar, shop panel, dialogue panel) wired throughsync.intentin both local and ws modes, with pure node-testable helpers inapps/dev/src/ui.ts(seedListFromSnapshot,shopRowsFromDef,dialogueChoicesFromNode,playerStatsFromSnapshot,buildingShopAt,dialogueActiveInWorld). Seedecisions.md` D35.
2026-08-13 Render-layer completions (D34) — MCP surface now 111 tools / 32 resources / 14 prompts / 20 namespaces. §7: two new content kinds — particle (content/particles/<id>.yaml, zod strict — {id, emitRate?, lifetime?: {min,max}, speed?: {min,max}, angle?, spread?, gravity?, size?: {min,max}, colors?, fade?}, all optional, documented render defaults) and shader (content/shaders/<id>.yaml, zod strict — {id, filter: blur\|tint\|grayscale\|sepia\|hue\|brightness, params?} — a parameterized BUILT-IN filter selection, no GLSL in content). SpriteDef gains optional filters?: string[] (shader ids applied to drawn frames); the sprite image layer gains optional frameX/frameY/frameW/frameH source-pixel crop rects (D34 sprite-sheet support — one asset + multiple sprite defs cropping frames; default whole image, D31 behavior unchanged). §8: RenderSnapshot gains optional particles?/shaders? catalogs and the viewport gains onEvent({event, payload}) (spawns particle bursts on particle events; both sync handles forward them). New pure modules: particles.ts (particleSpawn/particleTick/particleRadius/resolveParticleDef — burst spawn, px/s physics with gravity, expiry, size-shrink fade), filters.ts (filterParams — pure shader-def → numeric/color FilterParams; the viewport's makeFilter instantiates the cached PixiJS BlurFilter/ColorMatrixFilter), weather.ts (weatherEffects — built-in weather-id → {filterId?, particleId?, color?} mapping, engine mechanism), labels.ts (labelPosition + LABEL_ABOVE_OFFSET — the viewport draws a Pixi Text above any entity with a label field; no core schema change — label is a plain open-typed entity field). Viewport wiring: per-entity sprite containers carry filters; the map container gets the weather filter; a ParticleLayer Graphics steps/draws particles from the rAF loop; storm adds a translucent overlay color; sub-textures cached per asset + frame rect. §6: createEngineNamespaces 18 → 20 namespaces (particle.* + shader.*, creation-first lifecycle mirroring sprite.*). Sample-game content: particles/{dust,rain,sparkle,wind}.yaml, shaders/{rain-tint,sparkle-glow}.yaml, the starter-gift trigger fires a sparkle burst via emit-event, the parsnip-mature sprite carries filters: [sparkle-glow], and the mayor Mod spawn carries label: "Mayor". Tests: core particle-shader.test.ts (6), render particles/filters/weather/labels.test.ts + sprite-draw frame-crop cases (24 new), mcp particle-/shader-namespace.test.ts (14), client-surface counts + particle/shader case (20 new). 629 tests green (was 575). api.md, mcp.md, decisions.md (D34), AGENTS.md, packages/core (schema), packages/render (viewport, particles, filters, weather, labels, sprite-draw, sync), packages/mcp (particle, shader, index, server), apps/mcp-server (ws-broadcast, tests), examples/sample-game (content, mod)
2026-08-13 Core mechanism completions (D33) — MCP surface now 101 tools / 28 resources / 12 prompts / 18 namespaces. §1: the world map entity now carries the map's optional walkable (spread from the def in seedWorld). §4: MovementSystem gains findPath(npcId, to) → TilePoint[] \| null (pure A* grid query — 4-neighbor, Manhattan, array-based open set; path includes start and goal; blocks = blocksMovement buildings + other npc/player grid entities; never moves the npc; moveTo/teleport untouched); new packages/core/src/systems/pathfinding.ts (findPath, TilePoint); map-grid.ts gains isWalkableTile(map, x, y) — the single walkability source of truth (declared map.walkable value, else the UNWALKABLE_TILES fallback) — now used by MovementSystem.moveTo, IntentSystem.move, and PlacementSystem's buildable: "map" validation ("map" previously skipped the tile check like "any"; D33 makes it the explicit terrain-authored walkable form — no existing content breaks). TimeSystem.set gains an optional third param tickInDay = 0 (backward compatible). SaveSystem.snapshot includes time from TimeSystem.get() when a time system exists; restore calls time.set(day, season, tickInDay) when the snapshot carries time (absent → clock untouched). TriggerAction gains add (schema z.number().finite(); runtime: numeric field += value, missing field starts at 0, non-numeric field throws action add: field <path> is not a number; set-state stays absolute — the D29 language gap is closed). §5: HookContext/hooks unchanged (no new hooks). §6: createEngineNamespaces unchanged (18 namespaces); new npc.path tool in the npc.* namespace. §7: MapDef gains optional walkable?: Record<tileId, boolean>; new single-file content kind interact (content/interact.yaml, zod strict — {id, order: [harvest\|water\|sow\|till, ...]}; no MCP tools — weather/time precedent); WorldSnapshot gains optional time (version stays 1, old snapshots valid, migration-free). New pure helpers exported from core: findPath/TilePoint/isWalkableTile/WalkableMap/findMapEntity/InteractDef/InteractAction/INTERACT_ACTIONS/DEFAULT_INTERACT_ORDER. Sample-game: map declares walkable: {water: false} (explicit), content/interact.yaml with the default order, and the mayor-talk-quest gold reward switches from set-state to add (gold still ends at 100). 575 tests green (was 532; +39 core / +3 mcp / +1 client-surface). api.md, mcp.md, decisions.md (D33), AGENTS.md, packages/core, packages/mcp, apps/mcp-server/test, examples/sample-game/content
2026-08-13 Desktop packaging (D32) — Electron shell over the packaged static site. NO MCP surface change (100/28/12/18 held). §9: farm-pack gains --desktop (boolean) + --name <name> (product name, default "Farm Game") — parsePackArgs/PackArgs/PackResult extended; packGame writes the desktop shell into <out>/desktop/ when desktop: true (result.desktopDir). New generateDesktop(outDir, {productName?}) (apps/pack/src/desktop.ts): writes main.cjs (plain CJS — node:http server on 127.0.0.1 with ephemeral port 0, GET-only, correct MIME types, path-traversal guard, exact-files-only 404, SITE_ROOT = resolve(__dirname, "..") so it runs from any cwd; BrowserWindow 960×640 resizable, title = product name with page-title-updated prevented, contextIsolation: true + nodeIntegration: false; server.close() on window closed; window-all-closed → app.quit() on all platforms), package.json (npm-safe name — "Pinefield Valley" → pinefield-valley; main; scripts.start = "electron ."; devDependencies.electron ^ major — the runner installs it), and README-desktop.md (npm i && npm start). Electron added as a devDependency of @farm/pack + pnpm-workspace.yaml onlyBuiltDependencies: ["electron"] (pnpm 10 blocks postinstall scripts; electron's postinstall downloads the Chromium binary). Root script pnpm pack:example:desktop packages the sample game with the shell (window title "Pinefield Valley"). No installer/electron-builder, custom icons, or sidecar headless core in v1 (documented future paths — D32). E2E: real Electron launched via playwright _electron — HUD "Day 1, spring", window title "Pinefield Valley", Kenney textures (grass/water/tilled pixel-sampled), player sprite at spawn, interact-till, ArrowUp move, zero console errors. 532 tests green (was 528; +4 in apps/pack/test/pack.test.ts incl. node --check on the generated main.cjs). api.md, mcp.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/28/12/18 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) — binary assets as data-driven engine content. NO MCP surface change (100/28/12/18 held). §1: new engine.assets: ReadonlyMap<string, AssetEntry> (the binary asset table; shared with registry.assets). §2: ContentLoader.load scans content/assets/ for *.png/*.ogg → asset table (id = filename without ext, base64 {kind, mime, data}; unreadable skipped best-effort; no assets dir = empty table); loadBundle collects bundle.assets; watch re-scans assets on changes. ContentBundle gains optional assets?: Record<assetId, AssetEntry> (+assetEntrySchema; BUNDLE_VERSION stays 1 — additive, old bundles load unchanged). §7: SpriteDef layer union is now a strict discriminated union with the image layer {shape:"image", src, x?, y?, w?, h?} (src = asset id; crop clamped to source); MapDef gains optional tiles?: Record<tileId, assetId>; SoundDef.src is now a real audio asset id (src takes precedence over synth). §8: viewport loads image assets as PixiJS textures from data: URLs (cache by id, async, redraw-on-ready; map tiles drawn from map.tiles when the texture is loaded else tileColor); spriteDrawCommands gains an optional assets dims arg + emits image commands; SoundPlayer.setAssets + decodeAudioData src playback (buffers cached by id, silent skip on missing/undecodable); connectWs/connectLocal feed assets; new pure tileAssetId(map, tileId). Sample-game adopts 13 tiles + 8 sounds (map tiles, crop/building image sprites, sound src). Tests: core assets.test.ts (14), render image-layer/tileAssetId/soundSrcAssetId (+5), pack assets (2), sample-game/ws-broadcast assets assertions (+2). 528 tests green (was 500). api.md, mcp.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) — content bundle + self-contained static site. NO MCP surface change (100/28/12/18 held). §1: EngineOptions is now a union — exactly one content source required: {contentDir} (fs, unchanged behavior) or {bundle: ContentBundle} (packaged static site, no fs access); engine.contentDir is string \| undefined (undefined in bundle mode); the MCP creation namespaces' write helpers guard on undefined with a clear error (read-only bundle engines). §2: new ContentBundle type + contentBundleSchema + BUNDLE_FORMAT/BUNDLE_VERSION in packages/core/src/content/bundle.ts; ContentLoader.loadBundle(bundle) re-validates every def per-kind (defense in depth), reports per-item like load(), throws on structural invalidity; fs load()/watch() unchanged. §9 (new): apps/pack CLI (farm-pack --content <dir> [--out <dir>], default out ./dist-pack) — compileBundle (fs load + validate, per-kind/per-id errors, exit 1) + packGame (vite build of the apps/dev app in ?bundle= mode with baked __FARM_BUNDLE_DEFAULT__, then writes bundle.json). Packaged output: index.html + assets/ + bundle.json; opens with no query params. Mods NOT bundled (documented); Tauri desktop documented as future (not built); no pack.* MCP namespace (build-time tool — D30). apps/dev: ?bundle=<rel path> mode — fetch the bundle JSON over HTTP (fs shim bypassed), createEngine({bundle}), full LocalSync playability; the pack build bakes the default bundle path so the packaged game needs no URL params. Root script pnpm pack:example packages the sample game. Tests: core bundle.test.ts (7), apps/pack/test/pack.test.ts (8, incl. a full packGame → createEngine({bundle}) → tick integration). api.md, mcp.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): onPlayerIntent hook (15→16 hooks) + field-level stamina/skills seed + multi-quest chain + fishing minigame Mod. NO MCP surface change (100/28/12/18 held). §1: engine.start() seeding — the player entity now seeds stamina: 100, skills: {} alongside spriteRef (plain data fields, no new systems; D19). §5: HookContext gains optional payload?: unknown (set only by the onPlayerIntent wiring — the transient intent result {action, tile, ok, error?, detail?}, Mods cannot read it from world state); HOOK_NAMES gains onPlayerIntent (16 total), wired in engine.wireEvents as events.on("player-intent", (payload) => hooks.fire("onPlayerIntent", makeCtx(payload))) — fires for every IntentSystem.handle call, success AND failure. D25.4's "no hook" stance is reversed for the Mod hook-demand arm (see decisions.md D29.2). Content/sample-game: fish item; carrot-quest + mayor-talk-quest state machines (chained quests, player.questProgress.<id> convention); daily-stamina machine (condition on player.stamina + day-start restore) and fishing-treat machine (matches the Mod's mod:fish-caught event); mayor dialogue gains a quest-gated fishing-tip branch. Mod: sample-game-mod gains onPlayerIntent — fishing settlement on water tiles (fish +1, stamina −5, skills.fishing +1, mod:fish-caught/mod:fish-too-tired events, plot normalization). Known limitation: set-state is absolute (D20), so the mayor-talk-quest gold reward is a flat grant (increment action deferred). api.md, mcp.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; MCP surface now 100 tools / 28 resources / 12 prompts / 18 namespaces). §6: gateway.enable(name)/disable(name) — previously "no MCP tools yet" — are now surfaced as the server.enable_subsystem/server.disable_subsystem tools (exact contracts and error shapes in mcp.md §server). Both validate {name} is a non-empty string, convert the gateway's McpGatewayError throw on an unknown namespace to unknown subsystem: <name> error results, and return {name, enabled} with the new state. Guard rail: server.disable_subsystem {name:"server"} is rejected (cannot disable the server subsystem) — the server namespace hosts the re-enable tool. State is per-gateway in-memory (D9). Tests: packages/mcp/test/server-namespace.test.ts (mkdtemp pattern) + a client-surface case in apps/mcp-server/test/client-surface.test.ts driving disable/enable through the real MCP server. api.md, mcp.md, decisions.md, AGENTS.md, apps/mcp-server/test/client-surface.test.ts, packages/mcp/test/server-namespace.test.ts
2026-08-12 Deterministic seeded RNG (D28). New packages/core/src/random.ts: RandomSource interface (next() [0,1), nextInt(max) [0,max), setSeed(seed), state() → {seed, calls}), seedRandom(seed) (mulberry32-style, zero deps — same seed → identical sequence; state().seed is the anchor, calls counts draws since it; seeds normalize to uint32), and randomSeed() (unseeded default from Math.random + Date.now). §1: createEngine gains optional seed?: number; facade exposes readonly rng: RandomSource. §4: SystemInitCtx gains optional rng?: RandomSource (passed at init); WeatherSystem's default RNG resolves ctx.rng when available (constructor-injected WeatherRng still wins — tests unchanged); PlantingSystem's harvest drop chance draws from ctx.rng.next() instead of bare Math.random; SaveSystem snapshot carries optional rngState {seed, calls} from ctx.rng.state() and restore re-anchors via setSeed(rngState.seed) with calls reset to 0 (draw replay not built — documented). §7: WorldSnapshot gains optional rngState. Old snapshots without rngState remain valid (optional field, CURRENT_SAVE_VERSION unchanged, migration-free). New MCP namespace rng.* (§6) — rng.get/rng.set + rng://state, no prompts. api.md, mcp.md, decisions.md (D28), boundaries.md, AGENTS.md
2026-08-12 Presentation pipeline — render half (D27; round 2 of D26). packages/render: new pure sprite-draw.ts (sprite→draw-command mapping + v1 animation clock — frame selection by durationTicks-weighted cycle at a documented default 10 fps; building footprint scaling to w×h tiles; circle→ellipse for non-uniform footprints) and sound.ts (soundToAudioParams pure mapping + createSoundPlayer with a lazily-created AudioContext — autoplay-policy documented). viewport.ts: RenderSnapshot gains optional sprites/sounds; the draw path resolves entity spriteRef against the snapshot catalog — sprite entities draw their frame's layers (plots keep their tilled/watered shape base with the sprite on top; others replace the shape), missing refs/unknown ids keep the shapes.ts fallback unchanged; a rAF animation loop advances multi-frame sprites (only redraws when an animated sprite's frame index changes). sync/ws.ts: input(input) sends {"type":"input",input}; optional sound player option (catalog fed from snapshots, sound-play forwarded); WsSnapshotFrame declares sprites/sounds. sync/local.ts: input(input) maps key:Arrow* → intent("move", {tile}) one tile from the player's current position (client-side arrow mapping, same arrowDirection convention as the server adapter); optional sound player fed from the registry + engine.events.on("sound-play"); snapshot carries both catalogs. New keys.ts arrowDirection. apps/dev: arrow-key keyboard listener (keydown → sync.input) + createSoundPlayer wired to both sync modes with gesture prime(); new keys.ts keydownInput (pure). No core/MCP changes — surface stays 96/27/12/17. E2E (playwright, headless): player sprite pixel-verified (hat/body/head colors, no legacy blue dot), ArrowUp moved the player server-side with HUD feedback, interact till+sow produced a plot:8:4 with spriteRef: parsnip-sprout and a decremented HUD seed count, the sound-play coin frame flowed to a diagnostic WS client, and the snapshot carried 12 sprites + 4 sounds. §8 added. api.md, mcp.md, decisions.md (D27), architecture.md, boundaries.md, AGENTS.md, examples/sample-game/README.md
2026-08-12 Presentation pipeline — data/protocol half (D26). Two new content kinds: sprite (content/sprites/<id>.yaml, zod strict) — SpriteDef {id, frames: [{name?, durationTicks?, layers: [{shape:"rect"\|"circle", x, y, w?/h? or r?, fill:"#rrggbb"}]}]}, programmatic draw instructions, no atlas/image assets in v1 (image reference reserved Tier 3); and sound (content/sounds/<id>.yaml, zod strict) — SoundDef {id, synth?: {wave:"sine"\|"square"\|"triangle"\|"sawtooth", freq, durationMs, gain?, slideTo?}, src?} with at least one of synth/src (v1 synth-only; src file loading reserved Tier 3). Both are registered in contentSchemas/contentDirNames and exported from the schema index (D18 pipeline). Optional render hooks: CropStage.spriteRef?, BuildingDef.spriteRef?, AnimalStage.spriteRef? — missing = renderer falls back to shape drawing. Systems maintain entity appearance: PlantingSystem.sow sets plot.spriteRef = crop stage-0 ref (cleared on harvest with the crop fields); GrowthSystem.forceStage/advancePlot set plot.spriteRef per stage and spawnAnimal/forceAnimalStage/advanceAnimal set animal.spriteRef per stage; PlacementSystem.place copies def.spriteRef onto the building entity; engine.start seeds player.spriteRef = "player". Behavior language: play-sound upgraded from a headless no-op to emitting the sound-play bus event {soundId} (boundaries.md "Audio" row — engine emits events, render plays; round 2). No new systems, no new hooks (sprites/sounds are content kinds; the input mapping wires into the existing IntentSystem). §1/§4/§7 updated. api.md, mcp.md, decisions.md (D26), boundaries.md, architecture.md, AGENTS.md
2026-08-12 Player input via IntentSystem (D25). New auto-booted system (id "intent", registered after MovementSystem) exposing handle(action, args) → IntentResult {action, tile, ok, error?, detail?} — the single player-intent mechanism behind MCP player.intent, the WS {"type":"intent"} message, and connectLocal.intent. move validates coords → bounds → walkable tile (shared map-grid tileAt + UNWALKABLE_TILES) → building block (PlacementSystem.occupies), then sets player.x/player.y (does NOT go through MovementSystem.moveTo — npc-move untouched). interact applies the v1 priority order (mature → harvest; planted & unwatered → water; tilled & empty & seed → sow with the first seed-kind inventory item; un-tilled → till; else error), delegating to the existing PlantingSystem mechanisms. Every call emits the player-intent bus event (successes AND failures; behavior-language-matchable; NO new hook — D22.4 reasoning). No schema/content changes. Also: WS snapshot frames now carry time (from TimeSystem.get() at broadcast time); the render WsSnapshotFrame type matches; connectWs applyTime reads snapshot.time (fixes the HUD "booting…" stall) and gained intent() + onEvent/onSnapshot options; connectLocal gained intent() and now renders a full snapshot (map + entities) on refresh; viewport.tileAt(clientX, clientY) converts screen→tile accounting for the centered map offset + tile size. api.md, mcp.md, architecture.md, boundaries.md, decisions.md (D25), AGENTS.md
2026-08-12 WS multi-viewport sync (D8/D24) + engine.events facade exposure. Core change: Engine gains readonly events: WorldEventBus — the previously private bus is now a read-only facade property (same pattern as world/registry/hooks/systems), so the WS broadcast server can subscribe via engine.events.onAny(...) without any new accessor surface. apps/mcp-server: optional --ws-port/FARM_WS_PORT starts startWsBroadcast(engine, port) (src/ws-broadcast.ts) in the same process — snapshot frame on connect and on {"type":"sync"} client messages, {"type":"event", event, payload} broadcast to all clients per bus emission, non-serializable payloads skipped with a log (never a crash), close() on shutdown. packages/render: sync/ws.ts connectWs({url, viewport}) (handle shape identical to LocalSyncHandle — setTimeLabel/refresh/destroy; browser-native WebSocket, auto-reconnect, time label derived from day-start/time-tick-style payloads), viewport.render(snapshot?) now draws the snapshot (map def + entities via the new pure shapes.ts geometry — plots tilled/watered/crop-stage, buildings as blocks, npc/player/animal dots; map-optional createViewport degrades to map-only when no snapshot). apps/dev parameterized (?contentDir=/?modsDir=/?sync=ws&url=ws://...; ws mode boots no engine in the browser). MCP surface unchanged (85/22/10). api.md, mcp.md, architecture.md, boundaries.md, decisions.md, AGENTS.md
2026-08-12 crop.water fixed to mirror PlantingSystem.water (MCP-surface only; @farm/mcp; D-op2). The debug tool previously toggled plot.watered directly on the world entity (true→false→true), diverging from the mechanism and breaking the MCP-driven farming loop — after each crop.water→time.tick pair the flag flipped back to false, so waterRequired crop stages never advanced. It now delegates to PlantingSystem.water(x,y) (the SET mechanism), consistent with crop.stage→GrowthSystem.forceStage and crop.till/crop.sow/crop.harvest→PlantingSystem: {plotId, x, y, watered} with watered always true after success (watering an already-watered plot keeps it watered). Resolution/error conventions match crop.till (shared resolveCoordArgs; 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; surface counts unchanged (85 tools / 22 resources / 10 prompts). See mcp.md §crop. api.md, mcp.md
2026-08-12 crop.till / crop.sow / crop.harvest debug tools added (MCP-surface only; @farm/mcp; surface now 85 tools / 22 resources / 10 prompts). Three new debug tools in the crop.* namespace wrapping the existing PlantingSystem mechanisms (till/sow/harvest), following the crop.stage/crop.water debug-tool pattern exactly — no engine-core changes, no schema/content changes, no new hooks/events. Exact shapes: crop.till ({x?, y?, plotId?}) → {plotId, x, y, tilled}; crop.sow ({x?, y?, plotId?, seedItemId}) → {plotId, x, y, cropItemId, seedItemId} (cropItemId = the crop def id resolved from the seed, per PlantingSystem.sow); crop.harvest ({x?, y?, plotId?}) → {plotId, x, y, cropItemId, drops: [{itemId, qty}]}. All three share crop.stage/crop.water's coordinate/plotId validation (plotId → plotIdFor(x,y); no coords/plotId → error result crop: provide plotId or x/y coordinates) and try/catch the system call into an error result. See mcp.md §crop. api.md, mcp.md
2026-08-12 apps/mcp-server entry parameterized + examples/sample-game/ added. The stdio entry (apps/mcp-server/src/index.ts) no longer hardcodes the monorepo's content//mods/: exported DEFAULT_DIRS, resolveDirs({argv, env, cwd}), and main(dirs); roots resolve CLI (--content-dir/--mods-dir) > env (FARM_CONTENT_DIR/FARM_MODS_DIR) > default, relative CLI/env paths against process.cwd(); zero-arg behavior unchanged. main auto-runs on import only under direct execution. New examples/sample-game/ is a complete playable game (content + one mod) exercising every engine capability through MCP; integration-tested from apps/mcp-server/test/sample-game.test.ts. api.md, mcp.md, architecture.md
2026-08-12 Placement + NPC movement subsystems implemented (Tier 2 items 4 & 5; @farm/core + @farm/mcp; MCP surface now 72 tools / 20 resources / 9 prompts). Placement (PlacementSystem, id placement): new content kind building (content/buildings/<id>.yaml, buildingSchema in schema/building.ts) — { id, name?, footprint: {w, h}, buildable: "map"\|{tiles}\|"any", blocksMovement, description? } (footprint positive ints; {tiles} non-empty; "map"/"any" collapse behaviorally in v1 — documented). place(buildingId, x, y) validates def-exists → map-bounds → per-cell buildability → no-overlap (in that order), then creates a world entity {id: "building:<buildingId>:<x>:<y>", type: "building", buildingId, x, y, w, h, blocksMovement} and emits placement-change `{type: "place" "remove", buildingId, x, y, w, h}→ new hookonPlacementChange(added toHOOK_NAMES, wired inengine.wireEvents).remove(x, y)/list()/occupies(x, y). **Tile resolution**: a shared map-grid helper (systems/map-grid.ts) reads the map entity — terrain layer = the layer named"ground", else the first layer; tile id =terrainLayer.data[y][x]; the same helper feeds MovementSystem. v1 occupancy: buildings block buildings; the player and non-building entities are transient. **Movement** (MovementSystem, idmovement): no new schema — NPCs are world entities (type: "npc").moveTo(npcId, x, y)validation chain: coords → npc-exists (movement: unknown npc: ) → map bounds (movement: target out of bounds: (x,y)) → walkable tile (movement: tile (x,y) is not walkable) → not blocked by ablocksMovementbuilding (movement: blocked by building at (x,y)); then setsnpc.x/npc.yand emitsnpc-move{npcId, from, to}.teleport(npcId, x, y)skips bounds/walkability/blocking (still emitsnpc-move). **Walkability**: exportedUNWALKABLE_TILES = {"water"}— the engine's v1 unwalkable convention (the map schema has no per-tile walkability flag; a futurewalkable/terrain-cost flag replaces it). Blocking resolved viaPlacementSystem.occupiesfrom the system host; other NPCs do NOT block in v1. **NoonNpcMovehook** — thenpc-movebus event is behavior-language-matchable (on: "npc-move"), so the tri-criterion's Mod-demand arm fails. Engine auto-boots both systems (placementbeforemovement). New MCP namespacesplacement.(5 building lifecycle tools + 4 debug tools + 3 resources + 1 prompt) andnpc.` (5 debug tools + 1 resource + no prompts).
2026-08-12 Tick loop + animal age generalization implemented (Tier 2 items 5 & 6 — final Tier 2 build; @farm/core + @farm/mcp; D23; MCP surface now 82 tools / 22 resources / 10 prompts). Tick loop: engine.tick(dt) (dt in ticks) is the authoritative simulation driver — advances TimeSystem (boundary events as before) then runs every system's step(dt) in registration order; TimeSystem.step = advance(dt) + emit time-tick {day, season, tickInDay}; optional auto-tick via createEngine({ tickIntervalMs, tickDeltaTicks? }) (node:timers interval, default 1 tick per interval, unref'd, cleared on stop(); OFF by default). Intra-day scheduling: onSchedule at gains {tickInDay, day?, season?} matched on the time-tick event; existing "day-start"/"season-change"/{day?, season?} forms unchanged. Animals: new content kind animal (animalSchema, content/animals/<id>.yaml) — { id, stages: [{name, days, feedRequired}], produces: [{itemId, qty, chance?}] }; GrowthSystem generalized to advance type:"animal" entities (entity {id: "animal:<animalId>:<n>", type: "animal", animalId, x, y, stageIndex?, daysInStage?, fed?}) alongside type:"plot" in one advanceDay pass; spawnAnimal/feed/forceAnimalStage APIs; events animal-grow/animal-produce/animal-fed; new hooks onAnimalGrow/onAnimalProduce (added to HOOK_NAMES, wired in engine.wireEvents); animal-fed is bus-only (no hook, mirroring npc-move). See mcp.md §time/§animal and decisions.md D23. api.md, mcp.md, boundaries.md, decisions.md
2026-08-12 Initial draft (pre-implementation) api.md, mcp.md, architecture.md, decisions.md, AGENTS.md
2026-08-12 Walking skeleton implemented (@farm/core v0.1.0). Implemented deviations from draft: (1) kind→dir mapping via contentDirNames (content/maps/, single-file content/time.yaml); (2) WorldEventBus.onAny internal — engine forwards all bus events to systems' on; (3) ContentRegistry.remove(kind, id) internal — hot-reload registry swap; (4) ContentLoader.watch(dir) returns cleanup fn synchronously; (5) engine auto-boots a default TimeSystem (id "time") alongside user systems; (6) SystemsHost is a concrete class. MCP gateway moved to @farm/mcp (see §6); core holds a McpGatewayHandle placeholder. api.md, mcp.md
2026-08-12 Item + inventory subsystem implemented (@farm/core + @farm/mcp). Implemented deviations from draft: (1) ItemDef.kind concretized to tool\|seed\|material\|consumable\|decoration; use is an optional string (descriptive content only — no usage system in this slice); (2) price is an optional non-negative int; (3) Engine.contentDir added to the facade — creation tools need the content root to write content/items/<id>.yaml; (4) InventorySystem.take clamps to the available quantity and returns the actual taken count (0 when absent, no event emitted); give/take emit item-acquire/item-use bus events, mapped by wireEvents to onItemAcquire/onItemUse hooks (fresh HookContext); (5) inventory state lives on the player entity (player.inventory: { [itemId]: qty }), created in init; (6) engine auto-boots a default InventorySystem (id "inventory"). api.md, mcp.md
2026-08-12 Crop + planting subsystem implemented (@farm/core + @farm/mcp). Implemented deviations from draft: (1) CropDef.waterRequired concretized to a boolean (whether the plot must be watered during that stage) and drops to [{itemId, qty, chance?}] (chance optional 0..1, omitted = always) — v1 slice picks the simplest shapes; (2) plot entities are {id: "plot:<x>:<y>", type: "plot", x, y, tilled, watered, cropItemId?, stageIndex?, daysInStage?}, created by PlantingSystem.till; cropItemId stores the crop def id, resolved from the seed via crop.seedItem at sow time; (3) GrowthSystem.step(dt) is a separate crop clock — it accumulates day-fraction ticks against the time def's dayLengthTicks (read from the registry in init) and advances plots on its own day boundaries (not wired to TimeSystem day-start in this slice; nothing drives step(dt) automatically yet); (4) GrowthSystem.forceStage(plotId, stageIndex) is the debug mechanism behind crop.stage — validates plot/crop/index and emits crop-grow; (5) PlantingSystem.sow consumes 1 seed from player.inventory when an inventory field exists (emitting item-use); harvest grants drops into player.inventory (emitting item-acquire per drop) and emits crop-harvest; both mapped by wireEvents to hooks onCropGrow/onCropHarvest; (6) invalid operations throw Error — the MCP layer converts to error results; (7) engine auto-boots default GrowthSystem (id "growth") and PlantingSystem (id "planting"); (8) PlantingSystem.water sets watered: true (mechanism) while the debug tool crop.water toggles it (D3). api.md, mcp.md
2026-08-12 Shop + economy subsystem implemented (@farm/core + @farm/mcp). Implemented deviations from draft: (1) EconomySystem concretized to buy/sell/price with exact result shapes — buy/sell return {shopId, itemId, qty, total, gold}, price returns {shopId, itemId, buy, sell, stock} (buy = the price the shop pays the player from buys, sell = the price the shop charges the player from sells, stock = the optional sell-line cap, null when absent); (2) buy/sell convention fixed and documented: sells = shop sells to the player (player pays sells[].price), buys = shop buys from the player (player earns buys[].price); (3) stock is optional and informational in this slice — no depletion, no restock logic (declarative data only, consistent with D12 scope); (4) player money is a flat player.gold entity field initialized to STARTING_GOLD = 0 in init when absent/invalid (a documented constant; starting gold could become content-driven later); (5) buy grants items into player.inventory (emitting item-acquire) and sell removes them (emitting item-use) via the same direct field-manipulation pattern as PlantingSystem; both emit a transaction bus event {shopId, type: "buy"\|"sell", itemId, qty, total} mapped by wireEvents to the existing onTransaction hook (already in HOOK_NAMES); (6) invalid operations throw Error (MCP converts to error results) — unknown shop, item not sold/bought, insufficient gold/items, non-positive qty; (7) engine auto-boots a default EconomySystem (id "economy"); (8) ShopDef arrays may be empty (a shop may only sell or only buy). No loans/debt, no price volatility, no multiple currencies — flat buy/sell at content-defined prices. api.md, mcp.md
2026-08-12 Save + persistence subsystem implemented (@farm/core + @farm/mcp, mechanism only — D14). Implemented deviations from draft: (1) SaveSystem concretized to snapshot() → WorldSnapshot / restore(snapshot) → {restored: number} / migrate(snapshot, fromVersion?, toVersion?) → WorldSnapshot / validate(snapshot) → {ok, errors?} / registerMigration(fromVersion, fn); snapshot types live in schema/save.ts (WorldSnapshot interface + worldSnapshotSchema zod schema + SAVE_FORMAT = "farm-save" + CURRENT_SAVE_VERSION = 1), exported from the schema index but deliberately NOT a content kind (no content/saves/ dir, not in contentSchemas); (2) the WorldSnapshot wraps world.snapshot() (the existing Entity[] snapshot) with format, version, and a savedAt ISO timestamp; entities are shallow copies so the live world is never aliased; the snapshot covers world entities only — TimeSystem state is not serialized in v1; (3) restore validates the snapshot shape (throws Error on malformed input), rejects future versions (version > CURRENT_SAVE_VERSION), migrates to the current version, then replaces ALL world entities (removes every current entity, re-creates the snapshot's entities in order as fresh copies); (4) migrate is a mechanism over an empty migration registry — migrations are keyed by source version N and must yield a version-(N+1) snapshot; no-op when fromVersion === toVersion, throws on backwards migration, versions without a registered migration are identity steps (so forward migration currently returns the snapshot unchanged after validation); registerMigration is the public authoring surface for future migrations; (5) validate is structural against the save format (format literal, positive-integer version, non-empty savedAt, world array of entities with non-empty id/type); (6) save has NO hook points (architecture.md Tier 1 lists — for Persistence) and emits no bus events; (7) engine auto-boots a default SaveSystem (id "save"); (8) no save policy — no slots, autosave, or file I/O (snapshots are in-memory objects passed through MCP; disk persistence is future work). api.md, mcp.md
2026-08-12 Behavior language + trigger subsystem implemented (@farm/core + @farm/mcp, v1 core: conditions + triggers, D15). Implemented deviations from draft: (1) ConditionExpr concretized as a recursive union — comparisons {op: "eq"\|"gte"\|"lte", path, value} (value REQUIRED at validation, any JSON value, enforced via z.custom) and {op: "exists", path}, composed via {and: [...]} / {or: [...]} / {not: {...}} (the draft's single-object {op: "eq"\|"gte"\|"lte"\|"exists", path, value?} form was split so value is strictly required for the value-comparison ops and forbidden-shape-free for exists); conditionExprSchema uses z.lazy recursion + z.discriminatedUnion("op", ...) for the comparison arm. (2) TriggerSystem (id "trigger", auto-booted) exposes evaluate(condition, ctx) (pure), fire(triggerId) → {triggerId, fired} (throws trigger: unknown trigger: <id>, emits trigger-fire {triggerId} only when the condition is true — mapped by wireEvents to hook onTriggerFire, already in HOOK_NAMES), match(event, payload) → {fired} (runs all triggers whose condition is true, executing actions and emitting trigger-fire per fired trigger), and on(event, payload) delegating to match. (3) Path resolution convention (documented in §4): time.* → TimeSystem.get(), world.<entityId>.* → the entity, player.* → world.player.*, payload.* → the event payload (only during match/on), otherwise undefined. To resolve time.*, SystemInitCtx gained an optional systems?: SystemsHost field (engine passes the host; existing init call sites unchanged). (4) Loop guards (v1): match ignores the trigger-fire event itself, and matching is not re-entrant (an in-progress match/fire pass does not re-run matching on events its own actions emit) — this prevents infinite recursion from actions like give-item (which always emits item-acquire) under always-true conditions. (5) Action execution: set-state targets a world entity (player.<field> or world.<entityId>.<field>, missing entity throws), emit-event emits the named event with optional payload, give-item grants into player.inventory and emits item-acquire (direct field manipulation, same pattern as EconomySystem), play-sound/dialogue are accepted no-ops (headless v1 — no render/audio). (6) Schema registration: contentSchemas.behavior + contentDirNames.behavior = "behaviors" (content loads as content/behaviors/<id>.yaml); example content spring-gift (day ≥ 5 → give parsnip-seed) and season-welcome (day 1 of summer/autumn before a flag → emit-event + set-state). (7) TriggerAction explicit type uses value?: unknown and `payload?: Record undefinedto match zod's union inference underexactOptionalPropertyTypes— runtime validation still requiresvalueandpath` for their ops. No state machines (v2, D15), no timers/schedules, no async or random conditions.
2026-08-12 mod.* introspection namespace implemented (@farm/core + @farm/mcp, introspection only — D10; last Tier 1 subsystem). Engine facade (§1) gained a read-only mods surface, symmetric with contentDir: engine.modsDir: string \| undefined, engine.mods: ModStatus[] (a live getter over the closure-held loaded statuses, replaced on reload), and engine.reloadMods(id?) behind mod.reload. ModLoader (§5) gained reload(modsDir, id?) and now keeps a per-mod record { dir, unsubscribers } — each HookRegistry.on returns an unsubscriber, and reload unbinds the previous functions for a Mod (or all, with an unbind-all + re-scan for the no-id case) before re-importing the entry with a cache-busting ?t=<timestamp>-<seq> query so changed .mjs files are re-read; duplicate bindings are impossible because the old ones are removed first. A Mod that never loaded successfully in this process has no record and cannot be reloaded by id. D13 stands: in-process reload is the dev-loop convenience; the authoritative code-Mod path is still a headless-core restart (apps/mcp-server). No contentOverrides mechanism exists in the loader — Mods are code with an inline { id, version } manifest — so nothing beyond ModStatus is surfaced (see §7 row fix). mod.* has no prompts. api.md, mcp.md
2026-08-12 Behavior language v2 (state machines + onSchedule) implemented (@farm/core + @farm/mcp, D15/D20; Tier 2 lead item). Implemented deviations from draft: (1) new content kind statemachine (content/statemachines/<id>.yaml) with stateMachineSchema (schema/statemachine.ts) — { id, initial, states: [{id, onEnter?, onExit?}], transitions: [{from, to, when?, on?}], onSchedule?: [{at, to, when?}] }; a superRefine enforces cross-field validity (initial/from/to reference declared states, unique state ids, at least one of when/on per transition, at object form needs at least one of day/season). (2) TriggerSystem (id stays "trigger") extended into the v2 behavior engine rather than adding a sibling system — one match pass drives triggers first, then machines, under the same matching re-entrancy guard; trigger-fire still short-circuits the whole pass. (3) Machine state persists on a world entity {id: "sm:<machineId>", type: "statemachine", machineId, state}, created lazily on first transition (never at boot) so save.snapshot/save.restore persist machines for free and existing snapshots/tests are unaffected; machineStatus(machineId) and machineTransition(machineId, to) are the debug surface. (4) Each transition runs the leaving state's onExit, sets the instance state, runs the entering state's onEnter, then emits state-change {machineId, from, to} → new hook onStateChange (added to HOOK_NAMES, wired in engine.wireEvents); transitions to the current state are no-ops. (5) onSchedule is the v2 time primitive (no tick loop — the engine has no auto-driven tick, so schedules hook the existing day-start/season-change bus events; intra-day timing awaits a tick loop): at is "day-start"/"season-change" or {day?, season?} matched against the payload of those events, entries apply from any state after transitions, and the first entry whose at AND optional when hold wins. (6) transition matching requires transition.from to equal the current state, on to equal the event, and when (a v1 ConditionExpr, so no new expression language) to hold; first match in declaration order wins, at most one transition per machine per event. (7) new schema exports stateMachineSchema/scheduleEntrySchema/stateDefSchema/stateTransitionSchema/machineEntityId and types StateMachineDef/StateDef/StateTransition/ScheduleEntry/ScheduleAt/MachineEntity/MachineStatus/MachineTransitionResult/StateChangeEvent. (8) example content content/statemachines/daily-routine.yaml (NPC-schedule placeholder with a when-gated market-day schedule entry) and spring-quest.yaml (inactive→active→complete quest with give-item/set-state onEnter, quest progress at player.questProgress.<id>). v1 triggers unchanged — the behavior kind, TriggerSystem trigger APIs, and all v1 tests are untouched. api.md, mcp.md, boundaries.md, decisions.md
2026-08-12 Dialogue + weather subsystems implemented (Tier 2 items 2 & 3; @farm/core + @farm/mcp; MCP surface now 58 tools / 16 resources / 8 prompts). Dialogue (DialogueSystem, id dialogue): new content kind dialogue (content/dialogues/<id>.yaml, dialogueSchema with superRefine cross-field checks — unique node ids, startNode/to/choice to reference declared nodes, choices and to mutually exclusive, non-empty choices). Active dialogue state lives on a lazily-created world entity {id: "dialogue:active", type: "dialogue", dialogueId, nodeId, payload?} (snapshot-able; start() replaces any active dialogue without dialogue-end — hard debug reset). Node semantics: choices filtered by when (dialogue ctx: time.*/player.*/world.<id>.*/payload.* from the start ctx); to-only nodes present a synthesized Continue choice; terminal/all-gated nodes auto-end (text delivered, dialogue-end emitted, entity removed). Choice/node actions reuse the shared 5-kind action set via a new behavior-runtime.ts module (condition eval + path resolution + action execution extracted from TriggerSystem — one source of truth for the documented convention; TriggerSystem refactored to import it, zero behavioral change, 212 baseline tests still green). New hooks onDialogueStart/onDialogueEnd wired to dialogue-start/dialogue-end. Weather (WeatherSystem, id weather): new single-file content kind weather (content/weather.yaml, weatherSchema with superRefine — unique seasons, unique weather ids per season, defaults keys must be declared). State on a boot-created entity {id: "weather", type: "weather", weatherId} (initial = current season's first table entry, no boot RNG; entity only created when a def exists — games without weather.yaml boot cleanly). roll() on day-start via the System on interface; injectable RNG constructor arg (default Math.random) for deterministic tests; weather-change {from, to} emitted only on change → hook onWeatherChange. MCP namespaces dialogue.* (7 tools: lifecycle + debug start/choose; 2 resources dialogue://catalog + dialogue://{id}; 1 prompt dialogue.create-prompt) and weather.* (2 debug tools get/set; 1 resource weather://state; no prompts — single-file kind). Save tests updated for the new boot-created weather entity (save.test.ts, save-namespace.test.ts, client-surface.test.ts counts). Full decisions in decisions.md D21. api.md, mcp.md, boundaries.md, decisions.md