decisions.md 129 KB

Architecture Decisions

Source of truth for WHY the engine is designed this way. Every decision records its context, candidates, rationale, and — where the decision-maker corrected the proposal — the correction.

Process: decisions were reached through a grilling interview (/grill-me) over the four stated requirements:

  1. Every feature exposes an MCP server interface.
  2. Engine-level Mod support.
  3. Enforced data/logic separation ("data-driven creation").

Observed meta-fact: three of the four requirements are architecture/tooling concerns (MCP, Mods, data separation); none is a rendering/performance concern. The engine's core value is controllability by external tools/agents, extensibility, and content-as-data — not raw rendering throughput.


D1. Engine Boundary — Build on a Rendering Library

Decision: Build the engine layer on top of a mature rendering library (PixiJS). Own the architecture: ECS-style world, data pipeline, Mod loader, MCP layer.

Candidates:

  • A: From scratch (engine + renderer + everything)
  • B: Rendering library + self-built engine layer ← chosen
  • C: Full framework (Godot/Bevy)

Rationale: All three hard requirements (MCP, Mods, data separation) live in the layer we control. Rendering is commodity; architecture is the product.


D2. Technology Stack — TypeScript + PixiJS

Decision: TypeScript + PixiJS.

Candidates: Rust+macroquad, Lua+LÖVE (installed), Python+pygame, C#+MonoGame, TS+PixiJS ← chosen.

Rationale:

  • MCP's official reference implementation is TypeScript → lowest per-feature MCP cost.
  • Mods = JS modules, no embedded VM needed.
  • JSON + TS types fit the data-separation model.
  • A farming game never touches performance ceilings.
  • Free web export.

D3. MCP Purpose — Creation-First, Runtime Debug Second

Decision: MCP servers are primarily a creation bridge: AI agents build game content through MCP. Runtime MCP is for debugging only (state introspection, event triggering) — it does not serve players.

Correction: The interviewer initially recommended "runtime control as the core". The decision-maker corrected this: runtime MCP is debug-only.


D4. Data Layer — Forced Data-Driven Creation

Decision: The engine enforces data-driven creation. Game creation happens only through data (items/crops/maps/dialogue defined in content files). Content files contain no code; engine logic hardcodes no content.

Clarification (verbatim intent): "我的意思是用这个引擎创作游戏时强制数据驱动" — when creating a game with this engine, data-driven is mandatory.

Confirmed restatement: the engine provides logic systems; creators provide only data.


D5. New Mechanics — Declarative Behavior Language + Mod Escape Hatch

Decision: A + C combination:

  • Built-in declarative behavior data language for routine creation (conditions/state machines/triggers expressed in data).
  • Mods as the code escape hatch for long-tail complex mechanics.

D6. Mod API Boundary — Pure Hook Interface

Decision: Code Mods register to typed hook points (onDayStart(ctx), onCropHarvest(ctx)). Mods touch only context objects; they can never import engine internals.

Honest cost: UI/rendering-level Mods are impossible. Data Mods (adding content, overriding values) have zero barrier.


D7. Runtime Shape — Core/Render Separation, Browser-First

Decision:

  • The engine core is a pure TS library with zero DOM dependencies — world simulation, data layer, Mod loading, MCP all run headless.
  • Rendering is a PixiJS adapter layer. Dev runs in browser (Vite); desktop via Tauri packaging.
  • Headless core execution = MCP dev server, tests, CI with no window.

D8. Authority — Headless Node Core Is the Authority

Decision: The Node headless core is the source of truth; browsers are render viewports synchronized over WebSocket. Runtime MCP debug connects directly to the Node core process. Browser can disconnect/reconnect; multi-client is native. Architecture resembles "game server + client viewport".


D9. MCP Server Shape — Central Gateway + Namespace Registration

Decision: One central MCP gateway with per-subsystem namespace registration. Every subsystem (items/crops/maps/dialogue/save/Mods…) must register its own tools/resources/prompts namespace (item.create, item.validate, map.get, save.snapshot). One external server; stdio + streamable HTTP dual transport (stdio for dev bring-up, HTTP for runtime debug into the core process). Subsystems can be enabled/disabled independently.

"Every feature has MCP" = an architectural constraint enforced by the registration layer, not a process count.

Implementation note (2026-08-12): the enable/disable mechanism is surfaced as MCP tools server.enable_subsystem {name} / server.disable_subsystem {name} (spec'd since the walking skeleton, now implemented). Guard rail decision: server.disable_subsystem rejects disabling the server namespace itself (cannot disable the server subsystem) — that namespace hosts the very tool used to re-enable it; the alternative (allowing the disable, forcing a process restart to recover) was rejected. Enable/disable state is per-gateway in-memory only (D9) — not persisted, no engine-system impact.


D10. Mod–MCP Bridge — Forbidden

Decision: No Mod↔MCP bridge. The MCP surface covers engine-native capabilities only. Trust boundary: Mods serve gameplay only; MCP serves engine-native capabilities only; external agents never see Mod-registered tools.

Correction: The interviewer proposed (A) "Mods register mod.<id>.* MCP tools"; the decision-maker rejected it (B).


D11. Entity Architecture — Data Objects + Separated Systems (Light ECS)

Decision:

  • Entities = flat, schema-validated data objects (id + type + component fields).
  • Logic = pure-function systems operating on entity data (GrowthSystem.step(dt)).
  • Mod data extension = extra fields (schema override). MCP introspection = uniform query.

Rationale: Full ECS is overkill for a few hundred farming entities; OOP violates every established principle.


D12. MVP Scope — Tier 1, Architecture Reserves All Tiers

Decision:

  • Tier 1 (v1): tile maps/terrain, time system (day/season), planting (till → sow → water → grow → harvest), inventory/items/tools, economy (shop buy/sell), persistence.
  • Tier 2: NPCs + schedules, dialogue/story, friendship, buildings, animals (the behavior data language primarily serves this tier).
  • Tier 3: quests/events, weather, stamina, skills, minigames, multiplayer.

Architecture principle: every new subsystem = add schema + add system + register MCP namespace + declare hook points, like adding a plugin.


D13. Hot Reload — Data Hot-Reload + Code-Mod Restart

Decision:

  • Content data file changes → immediate hot reload (re-read, re-validate, inject; world state preserved) — covers 90% of creation work.
  • Code Mod changes → restart the headless core (sub-second in Node, save kept).

Rejected: full code hot-reload (stateful-hook abyss: lost closure state, duplicate event registration).


D14. Persistence Layer — Generic Serialization + Policy Left Blank

Decision: The engine provides:

  • World-state snapshot JSON serialization
  • Schema versioning + migration hooks
  • save/load API
  • MCP save.* namespace

Left to creators (data declarations or Mods): when to save, how many slots, autosave, save UI.

Rationale: mechanism vs. strategy. Serialization/versioning/validation are generic mechanisms every game needs (engine's job); save policy is game content (not the engine's job). Save policy can itself be declared in data (data/save.json).


D15. Behavior Language v1 — Unified Core + Subset

Decision: v1 establishes the unified language core — condition expressions (comparison/logic composition) + triggers (onEvent → actions) — but implements only the Tier 1 subset (growth/watering/tool/time triggers). State machine syntax deferred to v2 (needed only from Tier 2).

Rationale: the language core is a differentiator — creators build the "when condition → action" mental model from day one. Field-driven (A) accumulates migration debt; full language (B) front-loads state machines v1 doesn't need. One condition/trigger engine = one MCP namespace (behavior.trigger.create) + one Mod-hook bridge point.


D16. Audience — Self-Use with Complete, Rich Documentation

Decision (verbatim): "自用,但是要完整且丰富文档" — self-use, but with complete and rich documentation.

  • No semver / API-stability promises; versioning at will.
  • Documentation is written to the highest standard anyway — MCP interfaces, Mod API, and data schemas are documented interfaces by nature: without docs these capabilities are unusable.

D17. Sequencing — Walking Skeleton Vertical Slice

Decision: First a full-stack vertical slice: first YAML content → core loads world → minimal render shows map → MCP reads world state → empty Mod registers a Hook. All four capability chains (data/render/MCP/Mod) end-to-end. Then add horizontal subsystems (time → planting → inventory → economy → persistence).

Rationale: the four capabilities are interdependent; the slice exposes integration issues (how MCP reaches core state, how Mods register into core) in week one, not week ten. After the slice, every subsystem is a template operation: add schema + add system + register MCP namespace + declare hooks.


D18. Content Data Format — YAML Authored, JSON Internal

Decision (verbatim): "C: YAML 手写+JSON 内部" — creators author content in YAML; the engine normalizes to internal JSON, validates with zod, and registers. The MCP surface only ever sees JSON.

Correction: the interviewer recommended JSONC (A: JSON + comments); the decision-maker chose YAML authoring instead, accepting the conversion layer — readable content wins over toolchain friction for the human-facing authoring surface.

Consequences:

  • Engine gets a content loader/compiler stage: YAML → normalize → zod validate → register.
  • Hot reload watches YAML files.
  • MCP tools/resources/prompts and engine internals consume only normalized JSON.

D19. Engine/Game Boundary — Criterion + Ownership Model

Decision: A feature is built into the engine iff all three hold: ① dataization value (expressible as content data), ② Mod hook demand, ③ MCP manipulability. Ownership model: L0 infrastructure (engine, built) → L1 simulation mechanisms (judged by criterion) → L2 expression layer (behavior language) → L3 game strategy (100% creators).

Verdicts (15 domains, all confirmed by decision-maker):

  • Engine: dialogue/story (content-document system), weather (probability-roll mechanism), placement (grid geometry primitive), NPC grid-movement primitive (moveTo + blocking + npc-move event), behavior language v2 (state machines + onTime/onSchedule).
  • Behavior language / data layer: NPC schedules (time-triggered state machines), quests/events (state machines, progress in player.questProgress.<id>), friendship (npc.affection field), stamina (player.stamina field), skills (player.skills.* fields) — zero new engine code.
  • Render/Mod layer: UI/HUD (engine exposes UI logic state; render draws), audio (engine emits events; render plays), minigames (engine guarantees triggers + rewards; body lives in render/Mods).
  • Multiplayer: engine provides multi-viewport sync mechanism (D8 extension); multiplayer gameplay is creator content.
  • Animals: reuse GrowthSystem generalized to age field + animal schema + behavior language (no new system).

Pattern cheat-sheet: content document → engine system; random mechanism → engine system; geometry/spatial → engine primitive; temporal state → behavior language v2; deterministic numeric + rules → fields + language.

Full detail in docs/boundaries.md (must be kept in sync — D-op2).


D20. Behavior Language v2 — State Machines + Time Triggers (Tier 2 lead item)

Decision: v2 = v1 triggers + state machine syntax + onSchedule time primitive. Concretized by the v2 implementation (2026-08-12) as:

  1. Separate content kind statemachine (content/statemachines/<id>.yaml) with behavior.statemachine.* MCP tools under the existing behavior namespace — NOT a discriminated union inside the behavior kind. Rationale: the existing behavior.trigger.* tool names stay truthful (they are trigger tools, not machine tools), content dirs stay 1:1 per kind, and behavior://triggers keeps a clean meaning. A separate MCP namespace (statemachine.*) was rejected: the behavior language is one subsystem; splitting it would fragment server.info.subsystems for no gain.
  2. onSchedule over a tick loop: schedules hook the existing day-start/season-change bus events, so no new game loop. The engine has no auto-driven tick (GrowthSystem.step has no driver), so fine-grained intra-day timing is documented as a limitation awaiting a tick loop (Tier 2 later or the dev loop). at is "day-start"/"season-change" or {day?, season?} matched against the event payload; entries apply from any state and are evaluated after transitions.
  3. TriggerSystem extended in place (id stays "trigger") rather than a sibling StateMachineSystem. One match pass drives triggers first, then machines, under the SAME matching re-entrancy guard — machine actions emitting events during a pass are never re-matched in that pass (cascades/infinite loops stay impossible). A sibling system would need its own guard and would allow machine-triggered events to re-drive triggers mid-pass.
  4. Machine state lives on a world entity {id: "sm:<machineId>", type: "statemachine", machineId, state} — not an in-memory system Map — so save.snapshot/save.restore persist it for free and MCP can inspect it like any entity. Instances are created lazily on first transition (never at boot) so pre-existing worlds/snapshots and existing tests are unaffected.
  5. No new expression language: conditions reuse v1 ConditionExpr exactly (zod-validated, no arbitrary code). New hook onStateChange (fires per transition via the state-change bus event) gives quest-tracking Mods their machinery; onTriggerFire stays for triggers.

Rejected candidates: (a) behavior-kind discriminated union (muddies trigger tool names + resource semantics); (b) tick-based time eval (needs a game loop the engine doesn't have); (c) sibling StateMachineSystem (two re-entrancy guards, cascade risk); (d) in-memory machine state (not snapshot-able).

Full detail in docs/mcp.md (§behavior v2), docs/api.md (§4/§7), and docs/boundaries.md (must be kept in sync — D-op2).


D21. Dialogue + Weather (Tier 2 items 2 & 3) — content-document system + lightweight probability-roll system

Decision: Dialogue and weather are the next two engine subsystems (boundaries.md verdicts: dialogue = content-document system; weather = probability-roll mechanism). Concretized by the implementation (2026-08-12) as:

Dialogue (DialogueSystem, content kind dialogue):

  1. Active dialogue state lives on a world entity {id: "dialogue:active", type: "dialogue", dialogueId, nodeId, payload?} (created lazily on start), not a player field and not an in-memory system Map — save.snapshot/save.restore persist it for free and MCP inspects it like any entity. Mirrors the statemachine decision (D20.4).
  2. to-only nodes surface a synthesized Continue choice: the headless debug driver has one uniform advance action (choose(index)), so linear chains are drivable; the node's text is still presented before advancing. A node with choices and no to branches; a node with neither is terminal.
  3. Available choices are filtered by when at read time (dialogue ctx: time.*/player.*/world.<id>.*/payload.* from the start ctx — same convention as triggers), and choose indexes into that filtered list; a gated-out choice is hidden, not erroring.
  4. Terminal / all-gated nodes auto-end the dialogue (deliver the text, emit dialogue-end, remove the entity). Without this a headless dialogue with no outbound path would soft-lock — there is no dialogue.end tool (the engine UI logic state is read from the entity instead).
  5. Dialogue reuses the behavior language's condition/action runtime via a shared behavior-runtime.ts module (condition evaluation, path resolution, action execution extracted from TriggerSystem — one source of truth; TriggerSystem refactored to import it with zero behavioral change).
  6. dialogue.start/dialogue.choose are debug tools (D3) — dialogue is authored content; the runtime drive surface is for headless testing.

Weather (WeatherSystem, content kind weather):

  1. Weather state lives on a world entity {id: "weather", type: "weather", weatherId} created at boot when a weather def exists, so weather.get is always defined; snapshot-able via save.*. Games without content/weather.yaml boot cleanly (the system degrades: get/set/roll throw clear errors).
  2. Injectable RNG (new WeatherSystem(rng?), default Math.random) — the roll is a weighted pick over the current season's probability table (weights as positive ints), made deterministic in tests.
  3. Initial weather = current season's first table entry (no RNG at boot; deterministic).
  4. Single-file content kind (content/weather.yaml, like time.yaml) → no MCP creation tools and no prompt: per-id creation tools would be meaningless for a one-file table; the table is authored directly in YAML.
  5. Season read from TimeSystem.get().season at roll time (the time def owns season names); when the current season is uncovered by the table, the first season's table is used as fallback.
  6. weather-change {from, to} emitted only on actual change (set to the current weather is a no-op) → hook onWeatherChange.

Rejected candidates: (a) dialogue state as a player field (pollutes the player entity with transient UI state; awkward save semantics); (b) in-memory dialogue/weather state (not snapshot-able); (c) a dialogue.end/dialogue.next tool (soft-lock avoided by auto-end instead — one less debug tool); (d) weather creation tools (single-file kind); (e) a WeatherDef that duplicates the time def's season list (the weather table keys seasons by name, matching time.yaml, without re-declaring day counts).

Full detail in docs/mcp.md (§dialogue/§weather), docs/api.md (§4/§7), and docs/boundaries.md (must be kept in sync — D-op2).


D22. Placement + NPC Grid Movement (Tier 2 items 4 & 5) — geometry/spatial engine primitives

Decision: Grid placement and NPC grid movement are the final two Tier 2 engine subsystems (boundaries.md verdicts; the "geometry/spatial → engine primitive" cheat-sheet row). Concretized by the implementation (2026-08-12) as:

Placement (PlacementSystem, content kind building):

  1. BuildingDef is data (content/buildings/<id>.yaml): { id, name?, footprint: {w, h}, buildable: "map" | {tiles: string[]} | "any", blocksMovement, description? }. Footprint is the grid size in cells; placed instances are axis-aligned rectangles anchored at the placement origin. blocksMovement matters for the NPC primitive (consumed via occupies).
  2. buildable is a three-form union, not a boolean flag: the map's tile ids ARE the terrain vocabulary, so restricting by tile id list is the natural authoring form; "map" (any tile on the map) and "any" (tile check skipped entirely) behave identically in v1 — "map" signals the def was authored against the map's terrain and is the explicit form reserved for a future map schema with unwalkable tile flags. {tiles} requires every footprint cell's tile id to be in the list.
  3. Placed buildings are world entities {id: "building:<buildingId>:<x>:<y>", type: "building", buildingId, x, y, w, h, blocksMovement} — save.snapshot/save.restore persist them for free; the system stores nothing. Mirrors the statemachine/weather/dialogue entity-state decisions (D20.4/D21).
  4. Tile resolution convention: the map entity's terrain layer is the layer named "ground", else the first layer; the tile id is terrainLayer.data[y][x]. Shared map-grid helper (systems/map-grid.ts) so PlacementSystem and MovementSystem read tiles identically.
  5. v1 occupancy: buildings block buildings; the player and non-building entities are transient and do not occupy cells (NPC blocking is MovementSystem's concern). placement-change {type: "place"|"remove", buildingId, x, y, w, h} bus event → hook onPlacementChange — per the tri-criterion: placement outcomes are spatial (not behavior-language-expressible), build/removal Mods (build-quests, achievements) are plausible, and the debug tools already exist.
  6. Creation tools named placement.building.* — buildings are the subsystem's content kind, so lifecycle tools live under the namespace like item.*/crop.*; placement.building.list (defs) vs placement.list (placed instances) stay distinct.

Movement (MovementSystem, system id "movement", MCP namespace npc.*):

  1. No new schema — NPCs are world entities {id, type: "npc", x, y, ...}; the movement API is a system (not a free module) so it can resolve PlacementSystem from the system host (auto-booted before it) and keep one consistent blocking source.
  2. Walkability is a tile-id convention: the map schema has no per-tile walkability flag, so the engine treats the tile id "water" as unwalkable (exported UNWALKABLE_TILES; the walking-skeleton map's water tiles are the demonstration). A future map schema gaining a walkable/terrain-cost flag replaces this documented convention.
  3. Blocking: buildings block, NPCs do not (v1). Building occupancy is stable geometry — a perfect blocking check (via PlacementSystem.occupies, only when the building's blocksMovement is true). NPC-vs-NPC overlap would need a resolution policy (who yields?) — a simulation concern deferred out of the primitive.
  4. No onNpcMove hook: the npc-move bus event is already matchable by the behavior language (on: "npc-move" in a state machine transition), so the tri-criterion's "Mod hook demand" arm fails — Mods get the machinery via content, not a hook. The event's primary consumer is the render/sync layer. (Contrast with onPlacementChange, where the event also enables build-quest content but placement is strategic enough to justify the hook directly.)
  5. npc.teleport exists for headless testing/QA — reposition an NPC past blockers without writing a valid path; still emits npc-move so render sync stays consistent. npc.* has no prompts and no creation tools — NPCs are runtime entities, not authored content (a creator gives an NPC its schedule via statemachine content).

Rejected candidates: (a) buildability as a plain boolean (placeable: true) — loses the terrain-tile vocabulary the map already provides; (b) a per-entity walkable flag on the map — the map schema is frozen for this slice; (c) NPC-vs-NPC collision in the primitive (deferred policy); (d) an onNpcMove hook (see 4); (e) an in-memory placement map — buildings as entities get save.* for free.

Full detail in docs/mcp.md (§placement/§npc), docs/api.md (§4/§7), and docs/boundaries.md (must be kept in sync — D-op2).


D23. Tick Loop + Animal Age Generalization (Tier 2 items 5 & 6 — final Tier 2 build)

Decision: Close out Tier 2 with (A) an explicit simulation tick loop that drives GrowthSystem.step and enables fine-grained intra-day scheduling, and (B) the GrowthSystem generalization to animal age (boundaries.md item 6, D19 verdict: "reuse existing mechanisms + data — no new animal system"). Concretized by the implementation (2026-08-12) as:

Tick loop (A):

  1. engine.tick(dt) is the authoritative explicit driver (D8 — headless core has no render loop; MCP/debug/tests need deterministic stepping). dt is in ticks — 1 tick = 1 engine time unit, dayLengthTicks from the time def — for full determinism (no wall-clock dependence). tick advances TimeSystem, then calls every system's step(dt) through SystemsHost in registration order (TimeSystem first, GrowthSystem second — a system gains a step by implementing the existing System.step? slot; no new host machinery).
  2. TimeSystem.step(dt) = advance(dt) + emit time-tick. Each tick emits the time-tick bus event {day, season, tickInDay} (the post-advance state), which the engine forwards to systems' on like any event; TriggerSystem.match evaluates intra-day onSchedule entries on it under the same re-entrancy guard. Emitting from TimeSystem.step (rather than a special-case in engine.tick) keeps the SystemsHost pattern uniform and the ordering explicit: boundaries first (day-start/season-change from advance), then the intra-day tick event, then GrowthSystem's day accumulation.
  3. time.advance stays the raw clock primitive — no systems step, no time-tick. engine.tick and time.advance are therefore semantically different (simulation driver vs clock mover); both remain useful and tests using advance are unchanged (backwards compatible).
  4. Optional auto-tick: createEngine({ tickIntervalMs?, tickDeltaTicks? }) starts a node:timers interval (default tickDeltaTicks = 1 tick per interval; unref'd so it never holds the process open; cleared in engine.stop()). OFF by default so tests and headless MCP stay deterministic. This is the dev-loop convenience; explicit tick is authoritative.
  5. Intra-day scheduling form: at: { tickInDay: n } (n ∈ [0, dayLengthTicks)) is added to the v2 onSchedule union, matched against the time-tick payload's tickInDay (plus optional day/season). The existing "day-start"/"season-change"/{day?, season?} forms are untouched and remain evaluated only on those boundary events. The time-tick event fires once per tick with the final state (a multi-tick tick(480) emits one event), so the caller controls step granularity — this bounds per-tick evaluation to O(entries) and keeps the re-entrancy guard intact.
  6. MCP exposure: one debug tool time.tick {ticks?} (default 1) → the post-step {day, season, tickInDay}. It exists because advance (clock jump) and tick (simulation step) are semantically different and both are useful from MCP: advance for boundary testing, tick for driving growth/intra-day state headlessly. time.tick is debug-only (D3) — no player-facing surface.

Animal age generalization (B):

  1. No new system — GrowthSystem (id growth) is generalized in place: step(dt)'s advanceDay iterates BOTH type:"plot" entities (CropDef, zero behavioral change) and type:"animal" entities (AnimalDef) in one pass. Animals are isomorphic to crops (stages, days-per-stage, a per-day gate), so the only new mechanism is a fed flag mirroring the plot's watered.
  2. Animal entity {id: "animal:<animalId>:<n>", type: "animal", animalId, x, y, stageIndex?, daysInStage?, fed?} — the counter n (one past the highest existing suffix) keeps multi-instances and post-restore spawns collision-free; entities are world entities so save.snapshot/save.restore persist them for free (D20.4/D21 pattern).
  3. AnimalDef schema (content kind animal, content/animals/<id>.yaml): { id, stages: [{name, days, feedRequired}], produces: [{itemId, qty, chance?}] } — the growth cycle + produce table, mirroring CropDef's stage/drop model with feedRequired replacing waterRequired. Feed rules (what food, when) and produce/harvest rules are behavior-language content (decision #9 / D19): the schema holds only the mechanism-shaped data; the engine grants nothing.
  4. feed is the water analogue: GrowthSystem.feed(animalId) sets fed: true and emits the animal-fed bus event (behavior-language-matchable, e.g. on: "animal-fed"); the flag is consumed per day (reset on stage advance AND after a mature animal produces), exactly like watered resets per stage. A feedRequired stage with fed: false skips the day (no growth, no produce).
  5. Events: animal-grow {animalId, animalDefId, stageIndex, stage} on every stage advance (crop-grow analogue) → hook onAnimalGrow; animal-produce {animalId, animalDefId, stageIndex, stage} emitted per eligible day while mature (final stage) → hook onAnimalProduce. No animal-mature event and no onAnimalFed hook: animal-grow already covers reaching maturity, animal-produce covers ongoing production, and the animal-fed event is already behavior-language-matchable — the "Mod hook demand" arm of the tri-criterion fails for feed (same reasoning as D22.4's npc-move). The produce table itself is granted by language content hooking animal-produce (e.g. a state machine transition on: "animal-produce" → give-item egg).
  6. MCP animal.* namespace (subsystems 13 → 14): creation-first definition lifecycle (validate/create/update/list/get, mirroring crop.* — animals have per-id authored content, unlike the single-file weather kind) + debug tools spawn/feed/stage/entities (D3) + resources animal://catalog/animal://{id} + prompt animal.create-prompt. animal.list lists definitions (consistent with item.list/crop.list); placed instances are animal.entities (mirroring placement.building.list vs placement.list).

Rejected candidates: (a) a StepSystem/game-loop module separate from the engine facade — the loop is one method on the engine and a step slot systems already have; a module would duplicate the host; (b) auto-tick ON by default — breaks determinism for tests/MCP; (c) a wall-clock-based dt (tickMs) — wall-clock coupling breaks determinism; ticks are the engine's time unit (D8); (d) driving onSchedule intra-day from GrowthSystem's day accumulation instead of a time-tick event — the boundary-based match machinery is the existing, tested path and keeps triggers/machines/guard behavior uniform; (e) an AnimalSystem — D19 verdict is explicit: reuse GrowthSystem; a new system would duplicate stage progression; (f) an engine-side produce()/harvest() API that grants items — granting is strategy (what/when/how many is creator content), the mechanism is the event; (g) a separate animal-mature event — redundant with animal-grow (which fires on the advance INTO the final stage); (h) a third hook onAnimalFed — the bus event suffices (D22.4 reasoning).

Full detail in docs/mcp.md (§time/§animal), docs/api.md (§1/§4/§7), and docs/boundaries.md (must be kept in sync — D-op2).


D24. Multi-Viewport Sync — WebSocket Broadcast Channel (final Tier 2 item)

Decision: Deliver D8's multi-viewport promise as an optional WebSocket broadcast channel in the SAME process as the MCP stdio entry (apps/mcp-server --ws-port <port> / FARM_WS_PORT), a browser-native client in packages/render/src/sync/ws.ts (connectWs, handle shape identical to LocalSyncHandle), snapshot-driven viewport entity drawing, and a parameterized dev app (?sync=ws&url=ws://...). Concretized by the implementation (2026-08-12) as:

  1. WS is a separate channel, NOT an MCP namespace (D9-consistent): the broadcast server adds zero tools/resources/prompts. The MCP surface (85 tools / 22 resources / 10 prompts / 14 namespaces) is untouched — the channel serves a different client kind (render viewports, not agent tooling).
  2. Protocol — push with an optional pull: on connect the server sends {"type":"snapshot","map":MapDef,"world":Entity[]} (the map def is included because world entities alone cannot build a viewport), then broadcasts {"type":"event","event":string,"payload":unknown} per bus emission to ALL connected clients. A client may send {"type":"sync"} to request a fresh snapshot; the browser client does this after each event frame so it converges to latest state. Full-world event frames were rejected — the event is a change notification, state is pulled on demand (smaller frames, stable event contract).
  3. Engine event exposure: the WorldEventBus moves from a private closure to engine.events (readonly property, the world/registry/hooks/systems precedent). The broadcast server subscribes via onAny; the engine's existing systems-forwarding subscription is untouched. No hook changes, no new events, no tick/game-loop features added (D8: the core stays headless; the channel only mirrors what the core emits).
  4. Defensive serialization: every frame is JSON-serialized in try/catch — a non-serializable payload (circular ref, function) is skipped with a log line, never a server crash.
  5. Entity drawing is data-driven and canvas-free: packages/render/src/shapes.ts is a pure function from Entity[] to draw shapes (plots with tilled/watered/crop-stage colors, buildings as footprint blocks, npc/player/animal dots, neutral dot for unknown positioned kinds) — unit-testable in node without PixiJS; the viewport only maps shapes to Graphics calls. Simple shapes, no art — v1's render contract (D1: rendering is commodity; art is creator content).
  6. Dev app parameterization: apps/dev reads ?contentDir=/?modsDir= (default repo roots + LocalSync) and ?sync=ws&url=ws://... (no engine in the browser — connect, render on snapshot, label time from event payloads like day-start). Paths resolve against the repo root through the existing /__farm_fs middleware.

Rejected candidates: (a) an MCP sync.*/render.* namespace (WS is not agent-facing tooling; a namespace would bloat the 85/22/10 surface for no gain); (b) full-world snapshots as event frames (state pulled on demand instead — smaller frames, stable contract); (c) SSE/HTTP transport (D8 names WebSocket; WS is bidirectional, browser-native, and connection-stateful for reconnect); (d) any core tick/loop/render-loop features (the channel only mirrors core emissions — D8 authority holds).

Full detail in docs/mcp.md (transport section), docs/api.md (§1), and docs/boundaries.md (must be kept in sync — D-op2).


D25. Player Intent — One Input Mechanism, Three Thin Channels

Decision: Add a single engine mechanism for human/agent player input — IntentSystem (id "intent", auto-booted) exposing handle(action, args) → IntentResult {action, tile, ok, error?, detail?} with two primitives: move {tile:{x,y}} (relocate the player entity to a walkable tile) and interact {tile:{x,y}} (a smart context action on a plot tile). Every input channel is a thin adapter over the same handle: MCP player.intent (agents can play, D3 debug-tool precedent), the WS {"type":"intent"} client→server message (browser viewports), and connectLocal.intent (the local dev sync handle). Every handle call emits a player-intent bus event {action, tile, ok, error?, detail?} — for successes AND failures — so WS viewports auto-refresh and HUDs can show feedback. Concretized by the implementation (2026-08-12) as:

  1. Intent primitives are the engine mechanism; input channel adapters are thin: the engine's core value is controllability (D16) — the same handle that serves a browser canvas click also serves an MCP agent call, with identical validation and events. The WS broadcast channel and the MCP tool add no behavior; they parse arguments, call handle, and relay the result (WS relies on the bus emission for the event frame; malformed input produces a synthesized error frame, never a crash).
  2. Interact priority order is the v1 hardcoded engine default interaction primitive — explicitly a default, not a policy layer: mature crop → harvest; planted & not watered → water; tilled & empty & player has a seed → sow with the FIRST seed-kind inventory item (first key whose item def kind === "seed"; no seed → no seed in inventory); not tilled → till; otherwise an error result (e.g. crop <id> is not ready to harvest). The sow seed choice is documented as v1 (a seed picker is future UI). Making the priority order content-izable (a declarative interaction table) is a plausible follow-up but deliberately NOT built now — the ordering is engine-default content-able later per D19.
  3. Player move reuses the shared map-grid/placement helpers instead of MovementSystem.moveTo: walkability (UNWALKABLE_TILES={"water"} via map-grid.tileAt) and building blocking (PlacementSystem.occupies where blocksMovement) are the exact checks moveTo performs, but moveTo is npc-typed (requires type: "npc"). Reimplementing the same validation chain inside IntentSystem (via the shared helpers) is the least invasive option — MovementSystem and its npc-move behavior/events/tests are untouched. The event for player movement is player-intent (uniform for both intents), not npc-move.
  4. player-intent is bus-event-only — no onPlayerIntent hook (D22.4's npc-move reasoning): the event is behavior-language-matchable (on: "player-intent"), so Mods get the machinery via content, and the tri-criterion's "Mod hook demand" arm fails. Its primary consumer is the render/sync/HUD layer.
  5. player://state resource + player.intent tool: the namespace is a runtime-entity debug/input surface (no creation tools, no prompts — the player is not authored content, mirroring npc.*). The WS snapshot frame also gains time: TimeState (from TimeSystem.get() at broadcast time) so viewport HUDs show day/season immediately on connect instead of stalling on "booting…" until the first event frame; the client's applyTime reads both event payloads and snapshot.time.
  6. Uniform sync handle interface: both LocalSyncHandle and WsSyncHandle expose intent(action, args) (plus the existing setTimeLabel/refresh/destroy) — the dev app's interactive UI (Interact/Move toolbar + canvas clicks via viewport.tileAt + HUD feedback/stats) is identical in local and ws modes.

Rejected candidates: (a) a separate input/controller module outside the systems — an auto-booted System fits the plugin template (D12) and can resolve PlantingSystem/PlacementSystem from the host like MovementSystem does; (b) routing player move through MovementSystem.moveTo — would need npc-type loosening or a parallel generic API, touching tested npc behavior for no gain; (c) an onPlayerIntent hook — the bus event already suffices (D22.4); (d) content-izing the interact priority order NOW — the default-interaction ordering is engine mechanism for v1, content-able later (D19's "deterministic rules → fields + language" row); (e) a WS-only input path (no MCP/local) — the same mechanism must serve every channel (decision #1).

Full detail in docs/mcp.md (§player + transport), docs/api.md (§4), and docs/boundaries.md (must be kept in sync — D-op2).


D26. Presentation Pipeline — Programmatic Sprites + Synth Sounds + Raw Input (data/protocol half)

Decision: Add the data/protocol half of the rendering presentation pipeline (D1/D8: the engine is headless and data-driven; render viewports present the world): two new content kinds sprite (content/sprites/<id>.yaml) and sound (content/sounds/<id>.yaml) carrying programmatic draw instructions and synthesizer instructions respectively (v1: no atlas/image assets, no audio files — both reserved Tier 3); optional spriteRef render hooks on crop stages / building defs / animal stages (and the player seed); full MCP namespaces sprite.*/sound.*; the WS snapshot carrying both catalogs; a raw {"type":"input","input":string} WS message → input bus event with a built-in arrow-key → IntentSystem.move mapping; and play-sound upgraded from a headless no-op to the documented audio-event emitter. The render/audio/keyboard playback half is round 2 (render layer). Concretized by the implementation (2026-08-12) as:

  1. Sprites are content data (programmatic v1): SpriteDef {id, frames: [{name?, durationTicks?, layers: [{shape:"rect"|"circle", x, y, w?/h? or r?, fill:"#rrggbb"}]}]} (zod strict). Primitive draw instructions keep content data-driven, testable, and dependency-free — the engine already renders shapes without art (D24's shapes.ts). Image/atlas loading is documented as reserved Tier 3 (a future image/atlas reference field; not built).
  2. spriteRef is an optional appearance hint: on CropDef.stages[], BuildingDef, AnimalDef.stages[], and the seeded player entity (default "player"). Missing → the renderer falls back to the existing shape drawing, so existing content and tests are untouched. Systems maintain the entity-side field so the snapshot always carries the current appearance: PlantingSystem.sow → crop stage 0; GrowthSystem on crop/animal stage advance (and spawnAnimal → stage 0); PlacementSystem.place copies the def's ref; harvest clears the plot's ref with the crop fields.
  3. Sounds are content data (synth v1): SoundDef {id, synth?: {wave:"sine"|"square"|"triangle"|"sawtooth", freq, durationMs, gain?, slideTo?}, src?} (zod strict, at least one of synth/src). The src field is reserved (Tier 3 audio-file loading). The behavior-language play-sound {sound} action now emits the sound-play bus event {soundId} — the engine's audio-event mechanism per boundaries.md's "Audio | engine emits events, render layer plays" row (previously a no-op in headless v1); behavior-language-matchable (on: "sound-play") and broadcast as an event frame.
  4. Both catalogs ride the WS snapshot: the snapshot frame gains sprites: SpriteDef[] and sounds: SoundDef[] (from registry.list at snapshot time — small bounded sets) so render viewports get the draw/audio instructions in-band with the world state they present (no second request, D24's pull model stays intact).
  5. Raw input message is an engine mechanism (D25 extension): the WS client→server {"type":"input","input":string} message (e.g. "key:ArrowUp", "key:Space", "pointer:click:<x>:<y>") emits an input bus event {input} broadcast as an event frame — behavior-language-matchable via on:"input". Built-in mapping: key:ArrowUp/ArrowDown/ArrowLeft/ArrowRight additionally call IntentSystem.handle("move", {tile}) for the tile one step from the player's current position (the mapping is player-centered and lives in the broadcast server adapter, NOT in the intent mechanism — player.intent semantics are unchanged, D25). Unknown keys/prefixes are broadcast-only; malformed input never crashes (a synthesized input error frame is broadcast instead). Pointer clicks are broadcast-only in this slice (a future click → interact mapping is render/game-layer freedom).
  6. No new systems and no new hooks: sprites/sounds are inert content kinds (the plugin template's "add system" arm is satisfied by the existing systems maintaining the spriteRef field; the input mapping reuses IntentSystem). The tri-criterion's "Mod hook demand" arm fails for both (the sound-play/input bus events are behavior-language-matchable — D22.4/D25.4 reasoning).

Rejected candidates: (a) atlas/image assets or audio files for v1 — dependency-heavy and un-testable in a headless core; programmatic draw/synth instructions are the data-driven minimum that round 2's render layer can present immediately; (b) a new InputSystem/SpriteSystem — the input mapping is a thin adapter over the existing IntentSystem (least invasive, D25), and sprites/sounds need no runtime logic; (c) a dedicated sync/presentation MCP namespace — sprite./sound. are content-kind namespaces mirroring item.*, consistent with D9; (d) hardcoding entity appearances in the render layer — violates D4 (content-as-data); the spriteRef field + sprite catalog keep appearance data-driven; (e) putting the arrow mapping inside IntentSystem.handle — the mechanism must stay intent-shaped (move/interact); raw-input-to-intent translation is a channel concern (D25's thin-adapters rule).

Full detail in docs/mcp.md (§sprite/§sound + transport), docs/api.md (§1/§4/§7), and docs/boundaries.md (must be kept in sync — D-op2).


D27. Presentation Pipeline — Render Half (sprite drawing + Web Audio + keyboard)

Decision: Complete the D26 pipeline with its render half (round 2): packages/render draws entities by their spriteRef against the snapshot's sprites catalog, synthesizes sound defs on sound-play event frames, and both sync handles expose raw keyboard input. No engine-core/MCP changes — this is purely the render-layer playback the D26 contract reserved. Concretized by the implementation (2026-08-12) as:

  1. Sprite rendering is data-driven geometry (unchanged shapes fallback): the viewport resolves each entity's spriteRef in the snapshot catalog and draws the referenced frame's layers (rect/circle, #rrggbb) translated to the entity's tile pixels. A plot with a resolvable spriteRef keeps its shape base (tilled/watered rects) with the sprite layered on top; all other sprite entities draw the sprite instead of their shape. Missing spriteRef or unknown sprite id → the existing shapes.ts fallback, byte-for-byte unchanged. The pure sprite-draw.ts module holds frame selection + draw-command mapping so it is node-testable like shapes.ts.
  2. Animation clock v1 is a renderer-side rAF timer: each sprite frame's durationTicks is a relative weight against a documented default frame rate of 10 fps (DEFAULT_ANIMATION_FPS); a frame with absent/zero ticks weighs 1. The cycle length is totalWeight / fps; frameAtTime(sprite, elapsedMs) picks the frame whose weighted slice contains the elapsed time. Single-frame sprites are static (index 0). One viewport-global clock advances on requestAnimationFrame; the viewport redraws only when an animated sprite's frame index changed (the clock runs continuously but idle redraws are skipped). v1 is deliberately simple — no per-entity clocks, no blend/direction frames.
  3. Building footprint sprite scaling: a sprite def is authored in 0..32 tile space; an entity carrying w/h (tile footprint, e.g. the 3×3 coop) scales the whole frame by w/h, so the sprite covers the building's footprint box. circle layers under a non-uniform footprint draw as ellipses (Pixi ellipse); everything else draws at 1×1 tile.
  4. Web Audio playback with the autoplay policy in the renderer: sound.ts maps SoundDef.synth to oscillator parameters (wave/freq/durationMs/gain/slideTo — linearRampToValueAtTime for both the slideTo frequency sweep and a gain fade-out) and creates the AudioContext lazily on the first play attempt or prime() call from a user gesture (browsers gate audio behind gestures). No def / no context / no synth → silently skipped; playback errors never crash the viewport. The dev app calls prime() from its keydown/pointerdown handlers.
  5. Keyboard input stays on the thin-channel model (D25/D26): the dev app's keydown listener maps arrow keys to a raw "key:Arrow<…>" input and calls sync.input(input) — ws mode sends the D26 {"type":"input"} message (the server maps Arrow* → IntentSystem.move, unchanged), local mode maps the same arrowDirection delta client-side (one tile from the player's current position) into intent("move", ...). The shared keys.ts arrowDirection keeps both mappings identical. Only arrows are wired; key-repeat on hold is fine for v1.
  6. The sound player rides the existing sync handles: connectWs gains an optional sound option (catalog fed from each snapshot, sound-play frames forwarded); connectLocal gains the same option fed from the local registry plus engine.events.on("sound-play"). Both handles additionally expose input(input) so the dev keyboard handler is mode-agnostic.

Rejected candidates: (a) per-entity animation clocks / blend frames — the weighted global clock is the v1 minimum and keeps frameAtTime pure and testable; (b) managing AudioContext in the engine — playback is render-layer freedom (D1/D8) and the headless core must stay audio-free; (c) a new input namespace/MCP tool for keyboard — raw input is already a D26 mechanism; the keyboard is just another thin channel; (d) atlas/image loading or audio files — reserved Tier 3, unchanged (D26).

Full detail in docs/api.md (§8), docs/architecture.md, docs/boundaries.md (D26 rows updated), and examples/sample-game/README.md.


D28. Deterministic Seeded RNG — the ONE gap adopted from the LÖVE comparison (full adjudication)

Decision: Build a deterministic seeded RNG as an engine mechanism, mirroring LÖVE's love.math.RandomGenerator, and record the complete adjudication of the engine-vs-LÖVE grill session. The grill compared the engine against LÖVE (the reference farming-game framework) and surfaced several gaps; exactly ONE was adopted — seeded randomness. All other gaps were consciously declined for documented reasons and are NOT built. Concretized by the implementation (2026-08-12) as:

  1. A core RNG module is the single randomness mechanism (packages/core/src/random.ts, zero deps): RandomSource = { next(): number /* [0,1) */, nextInt(max): number /* [0,max) */, setSeed(seed): void, state(): {seed, calls} }. seedRandom(seed) returns a mulberry32-style pure PRNG — the same seed always produces the identical next() sequence (replay/tests/Mods). randomSeed() (from Math.random + Date.now) is the "no seed provided" default. Seeds are normalized to a uint32; state().seed is the anchor (the value most recently passed to setSeed/seedRandom), calls counts draws since that seed.
  2. The engine owns one RNG and injects it into systems: createEngine({ seed?, ... }) (seed optional) exposes readonly rng: RandomSource on the facade; SystemInitCtx gains optional rng?: RandomSource and createEngine passes engine.rng at init. WeatherSystem's default RNG switches to ctx.rng when available (the injectable WeatherRng constructor override stays — test-injected deterministic sequences keep working); PlantingSystem's harvest drop chance (previously bare Math.random) switches to ctx.rng.next(). No other system changes — the engine's only two randomness consumers now share one seeded source.
  3. MCP debug surface (18th namespace rng.*): rng.get → {seed, calls} (current state); rng.set {seed} (finite number required) → resets the RNG → {seed, calls: 0} (subsequent world randomness deterministic from that seed); resource rng://state → same shape. No prompts (debug surface, D3 pattern like weather.*/time.*). Reads engine.rng directly — no system needed. MCP is a development/debug tool, never a runtime player service (D3).
  4. Save carries the RNG anchor: WorldSnapshot gains optional top-level rngState?: {seed, calls} — SaveSystem.snapshot() includes it from ctx.rng, restore() restores it. Restore semantics (honest minimal): restore calls setSeed(rngState.seed) — the sequence re-anchors to the original seed and calls resets to 0; calls are NOT replayed (documented). Old snapshots without rngState remain valid (optional field, migration-free, CURRENT_SAVE_VERSION unchanged).
  5. Determinism is now testable end-to-end: same seed → identical next() sequence; nextInt within [0,max); setSeed resets; weather day-start rolls deterministic across same-seed engines; harvest drop rolls deterministic across same-seed engines (drawn from ctx.rng); save roundtrip preserves rngState; MCP rng.* wired through the gateway.

The full LÖVE-gap adjudication (the grill decisions, recorded faithfully):

Gap Verdict Reason
Seeded RNG (love.math.RandomGenerator) ADOPTED — BUILT (this decision) Determinism is an engine mechanism: replay of world sims, deterministic tests, and a shared seeded source for Mod-visible randomness. rng.* MCP + save rngState complete the mechanism (debug surface + persistence)
Text rendering (love.graphics.print) Declined Render-layer freedom (D1/D6): no render Mods, presentation lives in the render layer, D6's no-render-Mods boundary stands
Image/atlas loading + audio-file decoding (love.graphics.newImage / love.audio.newSource) ADOPTED — BUILT (D31, this round) Superseded by D31's asset pipeline: content/assets/ binary files become engine content (base64 in the bundle + WS snapshot), sprite gains an image layer (src = asset id), map gains a tiles tile-id→asset-id mapping, and sound.src is now a real audio asset id (rendered via decodeAudioData). The engine still never loads atlas files directly — single-tile assets by id, no atlas format — and rendering/decoding stays render-layer freedom (D1/D8)
Particle systems (love.graphics.particles) Declined Render-layer freedom; triggered via the engine's emit-event bus mechanism, rendered by the render layer
Physics (love.physics) Declined Grid-blocking covers the engine's collision needs (MovementSystem + PlacementSystem); no physics engine
Threading (love.thread / workers) Declined Not applicable — headless core is single-threaded; no perf need
Data compression (love.data) Declined Not applicable — no large-data pipeline in the engine
Window/OS integration (love.window/love.system) Declined Not applicable — headless core, browsers are render viewports (D8)
Video / sensors / joystick Declined Not applicable — no video pipeline, no device input surface in the engine
Tauri desktop packaging Deferred Architecture reserved; not an engine mechanism — deferred to a future packaging layer

Rejected candidates: (a) per-system private RNG instances — a single engine-owned source keeps draws orderable/replayable and gives rng.* one true surface; (b) restoring calls by replaying draws on save.restore — draw replay is trivial per-module but requires every consumer to opt in, so the honest minimal (seed restore + calls reset) is documented instead; (c) a rng.* creation tool / prompt — the RNG is not authored content and has no authoring guidance to offer (D3 debug-surface precedent).

Full detail in docs/mcp.md (§rng), docs/api.md (§1/§4/§7), and docs/boundaries.md (must be kept in sync — D-op2).


D29. Tier 3 Content — Stamina/Skills as Fields, onPlayerIntent Hook, Multi-Quest Chain, Fishing Minigame Mod

Decision: Close the first Tier 3 content round with the D19 verdicts realized as data/behavior/Mod content, not engine systems: (A) stamina/skills as seeded player fields owned by the behavior language + a Mod; (B) the onPlayerIntent hook (the only engine change, reversing D25.4's "no hook" stance — see 2 below); (C) a multi-quest chain as three chained state machines (starter-quest exists; carrot-quest + mayor-talk-quest added); (D) a fishing minigame Mod as the D19 "minigames live in Mods" escape-hatch demo. Concretized by the implementation (2026-08-12) as:

  1. Stamina/skills are seeded player fields, zero new systems (D19's "stamina (player.stamina field), skills (player.skills.* fields) — zero new engine code" verdict, held): seedWorld now seeds the player as {id:"player", type:"player", x, y, spriteRef:"player", stamina: 100, skills: {}}. No StaminaSystem/SkillSystem — the rules are content. Sample-game demonstrates both directions of the field+language model: (a) a condition on player.stamina — the daily-stamina state machine alerts (low state, on: "player-intent" transition when: player.stamina lte 30) when the fishing Mod drains stamina below 30, and its day-start schedule entry restores stamina via a set-state player.stamina 100 onEnter (the machine's initial state deliberately differs from the day-start target — scheduling a machine INTO its current state is a no-op that skips onEnter); (b) skills incremented by the Mod — the fishing Mod writes player.skills.fishing.
  2. onPlayerIntent is the one engine hook addition (16 hooks), reversing D25.4/D22.4's "bus-event-only" stance: the player-intent bus event was deliberately hook-free (D25.4) because it is behavior-language-matchable and its primary consumer is the render/sync/HUD layer. D29 reverses that for the Mod hook demand arm of the tri-criterion: a minigame Mod (fishing) is gameplay simulation that the declarative language cannot express — it needs imperative settlement (stamina arithmetic, skill accumulation, inventory mutation, a custom reward event) driven by a transient payload ({action, tile, ok, error?, detail?}) that is NOT readable from world state (unlike day-start/crop-harvest, whose hooks read world state via ctx.world). Mods can only register to typed hooks (D6/D13) — they cannot subscribe to raw bus events — so the hook is the only Mod-visible surface for the intent payload. This is exactly the D5/D19 escape-hatch split: content matches player-intent via the bus event; Mods interpose via the hook. Wiring: wireEvents gains events.on("player-intent", (payload) => hooks.fire("onPlayerIntent", makeCtx(payload))); HookContext gains optional payload?: unknown (other hooks keep a payload-less context — only the intent hook threads its event payload because it is transient). Fires for every IntentSystem.handle call, success AND failure.
  3. Multi-quest chain as chained state machines (D19's "quests/events (state machines, progress in player.questProgress.<id>)" verdict, held): carrot-quest activates on the day-start when player.questProgress.starter-quest is complete and completes on a crop-harvest with payload.cropItemId === "carrot" (reward: carrot-seed ×2 + questProgress.carrot-quest); mayor-talk-quest activates on the day-start when carrot-quest is complete and completes on dialogue-end with payload.dialogueId === "mayor" (reward: egg ×1 + gold + questProgress.mayor-talk-quest). The mayor dialogue gains a quest-gated branch node (when: exists player.questProgress.mayor-talk-quest) offering a tip + egg. Known language limitation surfaced by the gold reward: the behavior language's set-state is absolute (D20) — there is no increment action, so the "gold +N" reward is expressed as a flat grant (set-state player.gold 100). An additive action is deferred to the language; this content documents the gap rather than inventing an engine mechanism (D19: "deterministic numeric + rules → fields + language" — the engine does not grow an add opcode in this round).
  4. Fishing minigame Mod is the D19 escape-hatch demo (boundaries.md "Minigames | Mod/render layer" row): the sample-game Mod gains onPlayerIntent — when an interact intent lands on a water tile (map terrain read via ctx.world — tileAt-style lookup of the ground layer, the same "water" tile-id convention as UNWALKABLE_TILES), it settles the catch: fish ×1 (player inventory + item-acquire), stamina −5 (gate: stamina < 5 → "too tired" → emit mod:fish-too-tired and skip — a simple documented gate, no item, no drain, no skill), player.skills.fishing +1, and emits mod:fish-caught {fish, size, x, y, stamina, catches, day} — a custom bus event the behavior language CAN match (on: "mod:fish-caught"), demonstrated by the fishing-treat machine (every third catch pays a free egg). The Mod also removes the plot entity the engine's default till-on-interact created on the pond — the Mod normalizes engine-default behavior, the escape-hatch working as intended. Size is a deterministic pseudo-size (1 + (catches % 3)) — Mods have no RNG access (the seeded RNG is engine/world randomness, D28; Mod-visible randomness is not in scope).

Rejected candidates: (a) a StaminaSystem/SkillSystem/QuestSystem — D19's verdicts are explicit (fields + language; quests are isomorphic to state machines); (b) keeping player-intent hook-free and having the fishing Mod read the last intent from world state — intents are transient and never persisted, so there is nothing to read; (c) an add action for gold — a language extension, deferred (see 3); (d) exposing the engine RNG to Mods for fish size — Mods get deterministic content-friendly rules; a Mod-facing random surface is a separate decision; (e) a fishing minigame in the engine (system/schema/MCP namespace) — D19's "no built-in minigame framework" verdict holds, and this round demonstrates the opposite (minigame body in a Mod).

Full detail in docs/api.md (§1/§4/§7), docs/boundaries.md (stamina/skills/minigame rows marked demo'd), and docs/mcp.md (change log — the MCP surface is untouched; hooks are not MCP surface, D-op2), examples/sample-game/README.md.


D30. Game Packaging — Content Bundle + Self-Contained Static Site (v1)

Decision: Deliver game packaging as an engine capability: the farm-pack CLI (apps/pack, package @farm/pack) compiles creator content (YAML) into a content bundle (bundle.json, format "farm-bundle" v1 — kind → array of validated defs) plus a self-contained static site (index.html + JS assets) that runs in the browser through the engine's existing LocalSync mode with an in-memory content source. The packaged game ships with NO YAML files (the bundle is the single source of truth for packaged content), NO dev server, NO vite fs middleware, and NO headless core. Concretized by the implementation (2026-08-12) as:

  1. ContentBundle is a core type (packages/core/src/content/bundle.ts): { format: "farm-bundle", version: 1, content: Record<string, unknown[]> } with a structural zod schema (contentBundleSchema). ContentLoader.loadBundle(bundle) → LoadReport re-validates every def against the per-kind contentSchemas via safeParse (defense in depth — "content is always normalized + validated at the boundary" holds for bundles too), reports per-item exactly like load() (same LoadItemResult shape), and registers into the same registry. Structural invalidity (wrong format/version/shape) throws a clear error. The existing fs load()/watch() path is byte-for-byte unchanged (zero regression — the existing content.test.ts load tests guard it).
  2. createEngine gains bundle mode: EngineOptions is now (EngineContentSourceOptions | EngineBundleSourceOptions) & shared — exactly one content source is required: a filesystem contentDir (dev/authoring/headless) or an in-memory bundle (packaged). In bundle mode start() calls loadBundle instead of the fs loader and the engine touches NO filesystem (browser-safe; engine.contentDir is string | undefined, undefined in bundle mode). The MCP creation namespaces' write helpers guard on contentDir === undefined with a clear error (bundle-mode engines are read-only — they never run MCP in practice; the guard is defensive typing).
  3. farm-pack is an offline build-time tool, NOT an MCP namespace: packaging has no runtime surface — it consumes content once, at build time, and the output is static. An MCP namespace would be a debug/creation surface (D3) for a step that has no runtime engine counterpart; the engine mechanism is the bundle loader + bundle-mode facade, both of which ARE exposed. Documented reasoning; no pack.* MCP tools.
  4. Mods are NOT in the v1 packaged output: Mods are a server/authoring feature (D6 — code modules with hook registration, loaded by ModLoader in the headless core). Browser file:///static module imports are restricted, and the packaged site runs entirely client-side. Documented limitation; bundling Mods into packaged output is a future path.
  5. Desktop (Tauri) is documented, not built: per the D28 adjudication table ("Tauri desktop packaging | Deferred"), v1 packaging is the static site; the documented future path is a Tauri shell around the packaged static site (the same LocalSync bundle mode in a webview). No Tauri/Electron code ships in this round.
  6. The dev app gains ?bundle= mode (packaged builds reuse the SAME apps/dev UI — no parallel fork): when ?bundle=<path> is present (repo-relative in dev) or a baked __FARM_BUNDLE_DEFAULT__ define is set (the pack build bakes "../bundle.json"), main.ts fetches the bundle JSON over HTTP (the fs shim middleware is bypassed — the bundle is fetched, not fs-read), boots createEngine({bundle}), and runs the identical LocalSync UI (toolbar Interact/Move + keyboard + HUD + sound + sprites). The packaged index.html needs NO URL params — it defaults to the baked bundle.
  7. Validation at both boundaries: farm-pack fails loudly with per-kind/per-id error messages on any invalid def (exit 1); loadBundle re-validates at boot (cheap, defense in depth). The packaged game is created-content-only: the engine provides the mechanism (bundle format + loader + static shell), the game is just data — no gameplay logic in the pack tool.

Rejected candidates: (a) shipping the YAML files in the packaged output (the bundle is the single source of truth — no YAML parsing in the browser); (b) an MCP pack.* namespace (build-time tool, not a runtime capability — no MCP surface for it); (c) bundling Mods into the output (browser module-import restrictions + D6 boundary; documented future path); (d) Tauri/Electron desktop packaging now (D28 deferred verdict; the static site is the v1 distributable and a Tauri shell is the documented future path); (e) a parallel packaged UI (reuses the dev app's ?bundle= mode instead); (f) trusting pre-validated bundle defs without re-validation (the "always validated at the boundary" invariant is worth the cheap re-validate).

Full detail in docs/api.md (§1/§2), docs/architecture.md (packaging pipeline), docs/mcp.md (change log — no surface change), examples/sample-game/README.md, and docs/engine-guide-zh.md (packaging chapter).


D31. Asset Pipeline — Binary Assets as Data-Driven Engine Content (image layers + tile textures + audio-file sounds)

Decision: Close the D26/D27-reserved atlas/audio-file gap (and the D28 "image-atlas + audio files" adjudication row) with a binary-asset pipeline: files in content/assets/ (.png images, .ogg audio) become first-class engine content that YAML references by asset id — sprite gains an image layer ({shape:"image", src: <assetId>, x?, y?, w?, h?}), map gains an optional tiles mapping (tile-id → assetId for image tile drawing), and sound.src is now a real audio asset id (previously reserved, D26). Assets travel as base64 in the content bundle and the WS snapshot (bounded — "keep assets small" is the documented constraint), so packaged games and multi-viewport sync stay self-contained single files. NOT a content kind: no schema registration, no contentSchemas entry, no MCP namespace — assets are filesystem content, and MCP-crafted YAML references them by id (MCP surface byte-identical). Concretized by the implementation (2026-08-13) as:

  1. The asset table lives on the ContentRegistry and the engine facade: registry.assets: Map<assetId, AssetEntry> (and registry.asset(id)), surfaced as engine.assets: ReadonlyMap<string, AssetEntry>. AssetEntry is {kind: "image"|"audio", mime, data} where data is the file's base64. The ContentLoader scans content/assets/ for *.png/*.ogg on load() (asset id = filename without extension; unreadable files skipped best-effort), collects bundle.assets into the same table on loadBundle, and watch() re-scans the table on asset-file changes (same debounce as content kinds). No assets dir = empty table (root content/ loads unchanged).
  2. The ContentBundle gains an OPTIONAL assets?: Record<assetId, AssetEntry> — additive, BUNDLE_VERSION stays 1, old bundles without assets load unchanged. farm-pack (apps/pack) collects content/assets/ into bundle.assets at compile time; the packaged bundle.json stays pure JSON (base64-inlined binaries, self-contained single file).
  3. The WS snapshot frame gains assets (the full table, Object.fromEntries(engine.assets)); RenderSnapshot (client) and the sound player receive it the same way (connectWs/connectLocal feed setAssets). Base64-only for files in content/assets/ — no other binary ever enters the bundle/snapshot.
  4. SpriteDef layer union extends to three shapes (zod discriminated union, still strict): rect/circle unchanged; image = {shape:"image", src: assetId, x?, y?, w?, h?} — x/y position the image's top-left in the sprite's tile space (0..32 convention, default 0,0); w/h are the image's size in tile-space units where the crop never exceeds the source (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. Crop origin fixed at the source top-left in v1 (single-tile assets — the atlas/sprite-sheet format remains a documented future path, NOT built). Existing rect/circle content is untouched.
  5. MapDef gains optional tiles?: Record<tileId, assetId> (zod, blank ids rejected): when present, the viewport draws that tile id from the image asset aligned to the tile pixel box; when absent (or the id unmapped) the existing tileColor(id) palette fallback applies — data-driven, no hardcoding.
  6. Render half: the pure sprite-draw.ts mapping emits an image command ({kind:"image", assetId, x, y, w, h, sourceW?, sourceH?}), resolving default dest sizes from asset pixel dimensions (fed back in as ReadonlyMap<assetId, {width, height}> from loaded textures; fallback DEFAULT_IMAGE_SIZE = 32); viewport.ts loads image assets as PixiJS textures from data:<mime>;base64,<data> (cache by assetId, async load + redraw when the last pending texture resolves so the first frame doesn't stay stuck on fallbacks; missing texture → shape/tileColor fallback), draws image commands as Sprites (top-left crop via a sub-texture frame rect), and draws map tiles from map.tiles; sound.ts's SoundPlayer plays src defs by base64-decoding the OGG → decodeAudioData → AudioBufferSourceNode (decoded buffers cached by assetId, async; src takes precedence over synth, a missing/undecodable asset is silently skipped; the synth path is unchanged). Texture and buffer caches are keyed by assetId.
  7. Sample-game adoption proves the pipeline: examples/sample-game/content/assets/ ships 13 Kenney Tiny Farm tiles + 8 Kenney interface sounds (renamed to self-descriptive ids — grass-1, water-1, tilled-1, plant-sprout, plant-grown, plant-mature, carrot-sprout, carrot-grown, carrot-mature, coop-1, coop-2, fence-1, grass-2; click-1, confirm-1, error-1, back-1, drop-1, pluck-1, switch-1, bong-1). The farm map's tiles mapping binds grass/water/tilled to image assets; parsnip/carrot stage sprites draw image layers (plus programmatic root accents); the coop and fence draw barn/fence tiles; the four sound defs switch from synth to src asset ids (till→drop-1, water→pluck-1, harvest→confirm-1, coin→click-1); the player and chicken sprites stay programmatic (no suitable human/animal tiles in the pack — both paths demonstrably coexist).
  8. MCP surface is byte-identical (18 namespaces / 100 tools / 28 resources / 12 prompts held). No new namespace (assets.* deliberately does NOT exist), no new tools, no schema-kind registration, no protocol-message changes — the snapshot frame only gains the additive assets field. Assets are filesystem content; MCP-crafted YAML references them by id (a sprite.create with src: grass-1 validates against the sprite schema; the loader resolves the id at render time). Rationale: binary files are not MCP-creatable in a JSON-tool workflow (no base64 write surface, D3's "creation tools write YAML"), they are not introspection targets (the assets field IS the debug surface), and there is no runtime behavior to drive — so the tri-criterion's "MCP manipulability" arm fails; the mechanism the MCP surface serves is the id-reference + load-time resolution.

Rejected candidates: (a) an assets.* MCP namespace — see 8; (b) a new content kind (content/assets/ is a directory of binaries, not YAML defs — no schema to validate, no creation lifecycle, no catalog); (c) atlas/sprite-sheet support — the pack's 16x16 tile sheet exists, but the mechanism is per-asset id reference (creators copy tiles to individual files); a real atlas format with crop regions is a documented future path; (d) shipping raw binary files in the packaged output — base64-inlining keeps bundle.json the single self-contained source of truth (D30), and the browser can decode data: URLs without fetch/CORS concerns; (e) decoding/decoding in the engine — rendering and audio playback remain render-layer freedom (D1/D8), the headless core stores bytes and never decodes (browser-free); (f) auto-loading every asset into PixiJS at boot — textures are loaded lazily on first render (snapshot-driven) and cached by id; (g) a hard dependency on assets being present for every src reference — missing assets degrade to the existing shape/color/silent-skip fallbacks (the D26/D27 "degrades gracefully" invariant).

Full detail in docs/api.md (§1/§2/§7/§8), docs/architecture.md (asset flow), docs/boundaries.md (atlas/adopted verdict row + presentation row), examples/sample-game/README.md (assets section), and docs/engine-guide-zh.md.


D32. Desktop Packaging — Electron Shell over the Packaged Static Site

Decision: farm-pack gains a --desktop flag that writes a runnable Electron desktop app into out/desktop/ alongside the D30 static site. The desktop shell is a thin Electron wrapper: main.cjs starts a local HTTP server (node:http, 127.0.0.1, ephemeral port) serving the out directory and opens a BrowserWindow that loads index.html — the exact bundle-mode page already E2E-verified in the browser (createEngine({bundle}) + LocalSync, zero Node fs access, LocalSync arrow-key/click input unchanged). Tauri was evaluated and rejected for this environment: Tauri v2 needs webkit2gtk-4.1/javascriptcoregtk-4.1 system packages on Arch, which require sudo (unavailable) — Electron was chosen because it is npm-only (bundles Chromium, zero system dependencies) and the D30 static site is fully self-contained, so the shell has no packaging logic of its own. Concretized by the implementation (2026-08-13) as:

  1. generateDesktop(outDir, {productName?}) (apps/pack/src/desktop.ts) writes into out/desktop/ (the shell and the static site live together): main.cjs (the Electron main process), package.json (npm-safe name derived from the product name — "Pinefield Valley" → pinefield-valley; "main": "main.cjs"; scripts.start = "electron ."; devDependencies.electron written as a ^ major — the runner installs it), and README-desktop.md (run instructions: npm i && npm start inside desktop/).
  2. main.cjs is generated plain CJS (Electron main supports CJS natively): a node:http server on 127.0.0.1 with port 0 (ephemeral) serves the site directory with correct MIME types (.html/.js/.json/.png/.ogg/.css/…), GET only, a path-traversal guard (resolve within the served root, SITE_ROOT + sep prefix check), and exact files only (404 for missing paths — no client routing, so no index.html fallback). SITE_ROOT = resolve(__dirname, "..") — the app works from any cwd (all paths derive from __dirname). The BrowserWindow is 960×640, resizable, title = the product name, webPreferences: {contextIsolation: true, nodeIntegration: false} (the page needs nothing from Node); page-title-updated is prevented so the window title stays the product name (the page's own <title> would otherwise override it). window.on('closed') closes the HTTP server; window-all-closed → app.quit() on all platforms (a game, not a mac dock app).
  3. CLI surface: farm-pack --content <dir> [--out <dir>] [--desktop] [--name <name>] — --desktop (boolean) generates the shell after packGame; --name sets the product name (default "Farm Game"). parsePackArgs/PackArgs/PackResult gained the flag + optional desktopDir. pnpm pack:example stays static-site-only; pnpm pack:example:desktop packages the sample game with the shell. The sample game's window title is "Pinefield Valley".
  4. Electron as a devDependency of @farm/pack (for the E2E launch) + pnpm-workspace.yaml onlyBuiltDependencies: ["electron"] — pnpm 10 blocks postinstall scripts by default, and electron's postinstall is what downloads the Chromium binary (≈100 MB); without the allow-list a fresh pnpm install would silently skip it.
  5. A sidecar headless core is reserved for v2: v1 desktop runs the same client-side bundle (no Mods, no headless core — identical to the browser package). A future round could package the headless core as a sidecar for Mods + multiplayer gameplay (the multi-viewport WS sync mechanism, D24, is the transport). Documented future path, not built.
  6. No installer / no electron-builder in v1: v1 desktop = a runnable app (npm i && npm start); distributable installer packages (electron-builder/forge), custom icons, and auto-update are documented future paths. The default Electron icon is used.

Rejected candidates: (a) Tauri v2 (system webkit2gtk-4.1/javascriptcoregtk-4.1 deps require sudo on Arch — not installable here); (b) electron-builder/installers now (huge dependency tree for v1's "runnable app" bar); (c) bundling Mods into the desktop output (unchanged D30 boundary — server/authoring feature); (d) a pack.* MCP namespace (unchanged — packaging is an offline build-time tool with no runtime surface); (e) a parallel desktop UI (reuses the identical built dev app — zero render changes).

Full detail in docs/api.md (§9), docs/architecture.md (desktop flow), examples/sample-game/README.md (desktop section), and docs/engine-guide-zh.md. MCP surface unchanged (18 namespaces / 100 tools / 28 resources / 12 prompts).


D33. Core Mechanism Completions — A* Pathfinding, Data-Driven Walkability, add Action, Interact-Order Content, Save Covers Time

Decision: Close five long-documented core gaps in one additive round, all "mechanisms, not policies" per the operating principles: (A) A* grid pathfinding as a pure query mechanism (findPath + MovementSystem.findPath + MCP debug npc.path), finally replacing the D22 "no pathfinding" note — stepwise NPC movement stays future work, pathfinding is a query today; (B) map walkability becomes data-driven — MapDef.walkable?: Record<tileId, boolean>, with a single isWalkableTile source of truth (declared value wins, undeclared falls back to the UNWALKABLE_TILES constant) — the D22 "future map schema gaining a walkable flag replaces this" note is now realized, with zero content change for existing maps (water still blocked via the fallback); (C) the behavior language gains the add incremental action, closing the D29-documented language gap (set-state is absolute, D20 — the mayor-talk-quest gold reward was a flat grant "an increment action is deferred"); (D) the player interact priority order is content-ized via a new single-file interact content kind — the D25 "content-izable later" note is realized; applicability conditions stay engine mechanisms, only the ORDER is content policy; (E) save.* covers TimeSystem — an optional additive time field in WorldSnapshot, closing the D14 "TimeSystem state is not serialized in v1" note (old snapshots remain valid, CURRENT_SAVE_VERSION stays 1). Concretized by the implementation (2026-08-13) as:

  1. A* pathfinding is a pure, mutation-free engine mechanism (packages/core/src/systems/pathfinding.ts): findPath(map: MapDef | undefined, start, goal, isBlocked) → TilePoint[] | null — 4-neighbor grid movement (no diagonals — grid semantics), Manhattan heuristic, and a simple array-based open set (maps are small, ~16×16; a binary heap was considered and rejected — the array is simpler, dependency-free, and fast enough at this scale; documented in the module). Returns the path including start and goal, or null when unreachable / out of bounds / the goal is not walkable or blocked. Walkability per node = isWalkableTile(map, x, y) AND NOT isBlocked(x, y). MovementSystem.findPath(npcId, to) wraps it (blocks = blocksMovement building footprints via PlacementSystem.occupies + other npc/player grid entities — the pathing npc excluded), validates the npc/coords like moveTo, and never moves the npc. moveTo/teleport semantics are byte-identical — pathfinding is additive query surface; stepwise NPC movement along a path is documented future work. MCP debug tool npc.path (101st tool; npc.* 5→6 tools): {npcId, to:{x,y}} → {npcId, from, to, path} or {npcId, path: null, reason: "unreachable"|"out of bounds"|"blocked"} — the reason is derived by re-checking bounds/walkability/blocking so the tool can distinguish why the query failed.
  2. Walkability is data-driven with a single source of truth: MapDef gains optional walkable?: Record<tileId, boolean> (zod, exactOptionalPropertyTypes-safe). isWalkableTile(map, x, y) (map-grid, exported) resolves: out-of-bounds → false; map.walkable[tileId] declared → its boolean; undeclared → !UNWALKABLE_TILES.has(tileId) (the constant stays the documented default {"water"}). Consumers: MovementSystem.moveTo, IntentSystem.move, PlacementSystem buildable: "map", and findPath all use the helper — the direct UNWALKABLE_TILES checks are replaced (the constant's default behavior is unchanged, so existing content behaves identically: water still blocked; a map declaring walkable: {water: true} now opens water). buildable: "map" was upgraded from "skip the tile check" (previously identical to "any", D22) to "every footprint cell must be walkable" — the explicit terrain-authored form; no existing content places a "map" building on water, so no behavior breaks (verified against all placement/movement/intent/client-surface tests). The world map entity carries walkable (spread from the def in seedWorld), so runtime consumers and the npc.path reason helper read the same data.
  3. The add action closes the D29 language gap: TriggerAction gains { action: "add", path, value } (value: z.number().finite()). Runtime (behavior-runtime, same target resolution as set-state): numeric field → += value (negative works — decrement); missing field → treated as 0 then += value; field present but not a number → throws action add: field <path> is not a number (same error convention as set-state's missing-entity throw); missing entity → the shared no entity: <id> error (the target resolver now takes the action name so set-state's messages stay byte-identical). set-state semantics are unchanged (absolute, D20) — add is the new incremental op. Works everywhere the 5-kind action set works (trigger fire, state machine onEnter/onExit, dialogue node/choice actions). The sample-game mayor-talk-quest gold reward switches from set-state player.gold 100 to add player.gold 100 (same end state — the test asserts gold === 100 either way); player.questProgress.mayor-talk-quest stays an absolute set-state.
  4. Interact priority order is content policy (D25's "content-izable later" realized): new single-file content kind interact (content/interact.yaml, zod strict, { id, order: z.array(z.enum(["harvest","water","sow","till"])).min(1) } — single-file like time/weather, so no MCP authoring tools and no hooks). IntentSystem.interact reads the def at call time (registry.get("interact", "default") else the first def of the kind) and iterates its order array executing the FIRST applicable action; absent def → the built-in DEFAULT_INTERACT_ORDER = [harvest, water, sow, till] (zero behavior change for content without interact.yaml). Applicability conditions are engine mechanisms: harvest when crop mature; water when planted & not watered; sow when tilled & empty & has seed (first seed-kind inventory item); till when not tilled. Non-applicable actions 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). A custom order like [water, harvest, sow, till] waters a mature unwatered crop where the default harvests — order demonstrably matters.
  5. Save covers TimeSystem (additive, migration-free): WorldSnapshot gains optional top-level time?: { day, season, tickInDay } (zod optional — old snapshots without time remain valid; CURRENT_SAVE_VERSION stays 1). SaveSystem.snapshot includes TimeSystem.get() when a time system exists; restore calls time.set(day, season, tickInDay) when the snapshot carries time (absent → current clock untouched — existing behavior). TimeSystem.set gains an optional third param tickInDay = 0 (backward compatible). Weather state is already a world entity, so it round-trips through the existing world serialization — no extra work.

Rejected candidates: (a) a binary heap for the A* open set — maps are ~16×16 and the array is simpler/dependency-free (documented in the module); (b) stepwise NPC movement now — pathfinding is a pure query mechanism; making NPCs walk a path is a simulation concern (who drives the steps? scheduling is behavior-language content) and would touch moveTo semantics — documented future work; (c) an assets.*-style MCP namespace or authoring tools for interact — single-file kinds (time/weather) have no authoring tools (D3), and there is no runtime state to debug-drive; (d) keeping buildable: "map" identical to "any" — the D22 note explicitly left "map" as "the explicit terrain-authored form", and the walkability resolution is exactly that form; (e) serializing time into the world entities array (a synthetic entity) — a top-level optional snapshot field is additive and migration-free, mirroring the rngState precedent (D28); (f) a walkable-driven terrain-cost/traversal system — costs are a policy question (what's slower?); walkability is a mechanism (can you stand there?), and cost modeling is future work.

Full detail in docs/api.md (§1/§4/§7), docs/mcp.md (§npc/§player), docs/boundaries.md (unchanged), and examples/sample-game/README.md. MCP surface: 101 tools / 28 resources / 12 prompts / 18 namespaces (one additive npc.path debug tool).


D34. Render-Layer Completions — Particles, Built-In Filter Library, Sprite-Sheet Crops, Weather Visuals, Entity Labels

Decision: Add five rendering-layer mechanisms in one additive round, all sitting on the established render-layer freedom (D1/D8/D26/D27/D31) and the "mechanisms over policies" principle: (A) a particle content kind + event-triggered rendering — the behavior language already emits arbitrary events with payloads, so content triggers particle bursts through the existing emit-event action; no engine system, no new hook; (B) a shader content kind = a parameterized BUILT-IN filter library with NO GLSL in content — content selects one of six engine-provided PixiJS filters (blur/tint/grayscale/sepia/hue/brightness) and supplies only numeric/color parameters; sprite defs can carry filters: [shaderIds], and weather visuals can filter the whole map; (C) sprite-sheet support via optional frame-crop rects on the sprite image layer — a sprite-sheet is just one asset file with multiple sprite defs cropping different frameX/frameY/frameW/frameH rects into it; no asset-pipeline change; (D) weather visual presentation as an engine-provided built-in mapping (weather.ts: rain/storm → rain particles + rain-tint filter, wind → wind particles, storm → + overlay color, unknown/others → {}) — NOT content, because weather visuals are presentation mechanism (which weather has a rain tint is engine presentation policy, kept built-in and documented; content supplies the particle/shader defs it references); (E) entity text labels via a plain label entity field — no schema change (labels are arbitrary entity data creators already set via set-state/Mod spawns); the renderer draws a Pixi Text above the entity. Concretized by the implementation (2026-08-13) as:

  1. particle content kind (content/particles/<id>.yaml, zod strict, packages/core/src/schema/particle.ts): { id, emitRate?, lifetime?: {min,max}, speed?: {min,max}, angle?, spread?, gravity?, size?: {min,max}, colors?: ["#rrggbb",...], fade? } — 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). Trigger: the render layer listens for the particle bus event {event:"particle", payload:{particleId, x?, y?, entityId?}} — x/y are tile coords; absent → resolve from the named world entity (payload.entityId) or the player entity. Content triggers particles via the existing behavior-language emit-event action (verified: emit-event already carries payload through behavior-runtime.ts — no language change needed). Example: the sample-game starter-gift trigger fires a sparkle burst at the player. Rendering (packages/render/src/particles.ts, pure/node-testable): particleSpawn(def, xPx, yPx) derives per-particle initial state (position/velocity/size/color/lifetime) with Math.random (pure visual — deliberately NOT the deterministic engine RNG, D28; visual randomness does not participate in world simulation); particleTick(states, dtMs) integrates velocity/gravity and expires particles; the viewport holds a ParticleLayer (Pixi Graphics redrawn from the active list, world-space px). Fade choice (documented): a single Pixi Graphics cannot carry per-particle globalAlpha, so fade is approximated by shrinking each particle's radius toward 25% over its lifetime (PARTICLE_FADE_SHRINK_TO). No core system, no hooks.
  2. shader content kind (content/shaders/<id>.yaml, zod strict, packages/core/src/schema/shader.ts): { id, filter: "blur"|"tint"|"grayscale"|"sepia"|"hue"|"brightness", params?: {[k]: number|string} }. NO GLSL/shader code in content — the implementations ship with the engine (packages/render/src/filters.ts, pure: filterParams(def) maps a def to numeric/color FilterParams; the viewport's thin makeFilter instantiates the concrete PixiJS BlurFilter/ColorMatrixFilter): blur {strength} (default 4), tint {color:"#rrggbb"} or {r,g,b} (default white), grayscale {factor} (default 1), sepia {}, hue {degrees} (default 0), brightness {factor} (default 1). Sprite integration: SpriteDef gains optional filters?: string[] (shader ids); the viewport draws each sprite entity into its own child container with the resolved filters applied (one container per entity, cached Filter instances per shader id). Missing/unknown shader id at use time → filter silently skipped (log nothing — a cosmetic miss never crashes the viewport). Whole-map filter: the weather visual applies a shader to the entire map container. Sample content: rain-tint (grayscale 0.7, used by rain/storm weather) and sparkle-glow (brightness 1.2, demoed on the parsnip-mature sprite).
  3. Sprite-sheet support (mechanism, test-covered only): the sprite image layer gains optional frameX?/frameY?/frameW?/frameH? (source-pixel crop rect into the asset image; absent = whole image — D31 behavior byte-identical). spriteDrawCommands carries the frame rect through the image command (clamped to the remaining source pixels when asset dims are known); the viewport creates a sub-texture (new Texture({source, frame})) from the cached base texture, cached keyed by assetId + frame-rect string. A sprite-sheet is one asset file + multiple sprite defs cropping frames into it. The sample game's Kenney tiles are single-frame, so no example art is fabricated — the mechanism is documented and unit-tested (sprite-draw.test.ts frame-crop cases + the sub-texture keying is thin Pixi code).
  4. Weather visual presentation (packages/render/src/weather.ts, pure): weatherEffects(weatherId) → {filterId?, particleId?, color?} — built-in mapping: sunny/snow/unknown → {}; rain → {particleId:"rain", filterId:"rain-tint"}; wind → {particleId:"wind"}; storm → {particleId:"rain", filterId:"rain-tint", color:"#2f3b4c"} (overlay color = translucent dark storm rect). The viewport reads the weather entity ({id:"weather", type:"weather", weatherId}) from the snapshot world on each render, diffs against the previous weather id, and on change: clears the previous filter/particles/overlay, applies the new map filter (cached), and starts periodic particle bursts (every ~900 ms at a random tile within the map, driven from the existing rAF loop). Unknown weather id → {} (no crash). This mapping is engine mechanism, not content: whether rain desaturates the screen is presentation policy the engine ships; the defs it references (rain/wind particles, rain-tint shader) are sample-game content.
  5. Entity text labels: entities carrying a label?: string field draw a small Pixi v8 Text (12 px Arial, white with a black 3 px stroke) anchored above the sprite/shape. packages/render/src/labels.ts (pure): labelPosition(entity, tileSize) → {x, y} (tile-center x, offset LABEL_ABOVE_OFFSET above the tile top). No core schema change — label is a plain entity field (the Entity type is open), so creators set it via set-state (world.<id>.label) or a Mod spawn; the sample-game mayor Mod spawn carries label: "Mayor" (the only content change needed).

Scope discipline (what stayed render-layer-free): no new engine systems (particles/shader/weather-visuals/labels are pure presentation); no new hooks (16 stay — particles arrive via the existing bus event; weather state already exists); no engine logic hardcodes the particle/shader defs or the weather-visual defs — the engine only ships the filter implementations and the weather-id→effect mapping (presentation mechanism), while creators own the data (which particles, which filters, what the sample game's weather looks like). The particle/shader MCP namespaces are creation-first lifecycle surfaces (validate/create/update/list/get + catalog + {id} resources + create-prompt) mirroring sprite.*/sound.* (D26) — particle/shader defs are authored content with a JSON-creation surface.

Rejected candidates: (a) GLSL/shader-code strings in content — rejected outright (the tri-criterion "content-as-data" pillar and the D26/D31 precedent: content parameterizes, the engine implements); the built-in filter library is closed and documented, custom shaders are future work; (b) a particle emitter "system" in the headless core — particles are pure visual presentation with no simulation consequence; the existing event bus is the engine-side mechanism, the renderer owns the lifecycle (no new systems, D-op2 "no systems for presentation"); (c) wiring weather visuals as content (a weathervisual kind) — weather visuals are presentation mechanism with a tiny, stable built-in mapping; making it a content kind would add authoring surface with no policy payoff (unlike interact D33, which changes player-facing behavior); (d) per-particle alpha fade in the Graphics layer — not possible in a single Pixi Graphics; size-shrink fade is the documented v1 approximation; (e) stepwise NPC movement along paths — still future work (D33); (f) fabricating multi-frame sprite-sheet art for the sample game — the Kenney assets are single-frame; the mechanism is documented + unit-tested instead.

Full detail in docs/api.md (§7/§8) and docs/mcp.md (§particle/§shader/§change-log). MCP surface: 111 tools / 32 resources / 14 prompts / 20 namespaces (two additive namespaces × 5 lifecycle tools + 2 resources + 1 prompt each).


D35. In-Game UI + Player-Intent Action Extension (seed picker, shop panel, dialogue panel, inventory bar)

Decision: Close the "no in-game UI" known-gap (the dev app was a bare toolbar + HUD; the engine's interact sow always used the first inventory seed, and there was no shop/dialogue/inventory UI) by (A) extending the player-intent action set — the single player-input mechanism (D25) — with five new actions that route every UI interaction through the exact same IntentSystem.handle → player-intent event path a human/MCP/WS drive: buy/sell (delegate to EconomySystem, the transaction is the result detail), talk (start an NPC's dialogue from the NPC entity's new dialogue field), choose (advance the active dialogue), and select-seed (set/clear player.selectedSeed); (B) fixing the D25 sow gap — interact's sow now prefers player.selectedSeed when it names an owned seed item, falling back to the first inventory seed otherwise (the field survives a successful sow — clearing is creator policy); (C) making shops discoverable from world state — BuildingDef gains an optional shop?: string (a shop def id) copied onto placed building entities, and the WS snapshot frame gains an additive shops?: ShopDef[] catalog (local mode reads the registry); (D) building the UI as dev-layer DOM panels (apps/dev) wired exclusively through sync.intent, with the display logic extracted into pure node-testable helpers (apps/dev/src/ui.ts) — no new dependencies, both local and ws modes. Concretized by the implementation (2026-08-13) as:

  1. Five new intent actions, one mechanism (all keep the player-intent event on success AND failure, and the IntentResult shape — detail widened from string to unknown so the new actions 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 economy errors passed through); talk {npcId?|tile?} resolves the NPC (by id, or the npc entity ON the given tile — npcId wins), reads its dialogue?: string entity field, and calls DialogueSystem.start(dialogueId) with the DialogueState as detail (errors: talk requires npcId or tile, unknown npc: <id>, no npc at tile, npc <id> has no dialogue); choose {index} calls DialogueSystem.choose(index) with the next node state as detail (errors: choose requires a non-negative integer index, no active dialogue, out-of-range passthrough); select-seed {seedItemId?} sets player.selectedSeed to a registered seed-kind item id (absent/empty clears the field) with {selectedSeed: string|null} as detail (error: <id> is not a seed item).
  2. player.selectedSeed is a plain player-entity field — snapshot-able by save.*, Mod-readable, content-addressable via conditions (player.selectedSeed path) — with documented semantics: interact sow prefers it only when it names an owned seed-kind item (stale selections degrade to the first-seed fallback), and a successful sow keeps the field (creator policy clears it; the field is set through select-seed). This closes the D25 "sow always uses the first seed" note with a mechanism (a seed selection the creator/player controls), not a policy.
  3. Shops are discoverable from world entities: BuildingDef.shop?: string (optional, additive — existing building content untouched) is copied onto placed instances (building.shop) by PlacementSystem.place, so any UI can find "the shop at this building" from snapshot world state (placement is runtime — placement.place — so the sample game keeps buildings unplaced and the demo arc/player places them, matching the D22 no-starter-placements precedent). The WS snapshot frame gains an additive shops?: ShopDef[] catalog (mirroring the sprites/sounds/particles/shaders/assets catalogs, D26/D31/D34) so ws-mode viewports get shop defs in-band; local mode reads engine.registry. The server-side {"type":"intent"} gate accepts all seven actions (message shapes unchanged — the D35 protocol delta is the additive shops field only).
  4. The UI is dev-layer DOM panels with a pure-helper testing strategy: apps/dev/index.html gains a seed-picker row, an inventory bar (items + gold + stamina + current seed), an overlay dialogue panel (text + choice buttons), and an overlay shop panel (buy/sell rows with owned counts) — all styled with the existing minimal CSS. apps/dev/src/ui.ts holds the pure logic (playerStatsFromSnapshot, seedListFromSnapshot, shopRowsFromDef, dialogueChoicesFromNode, buildingShopAt, dialogueActiveInWorld) — node-unit-tested (ui.test.ts); main.ts is thin DOM wiring that routes every interaction through sync.intent (select-seed/buy/sell/talk/choose), driving both local and ws modes uniformly. Dialogue panel content is event-driven (the player-intent talk/choose result detail carries the node state — the snapshot carries no dialogue defs), visibility is world-driven (dialogue:active entity presence). Documented ws-mode limitation: the snapshot carries no item defs, so the seed picker offers every owned item in ws mode (the engine validates select-seed); local mode filters to seed-kind items via the registry.
  5. MCP surface is byte-identical in count (20 namespaces / 111 tools / 32 resources / 14 prompts held) — player.intent's inputSchema extends only (action enum + optional arg fields), no new tools/resources/prompts (the panels drive the existing intent mechanism; shop/dialogue content is already MCP-authorable via shop.*/dialogue.*). Sample content: content/buildings/shop.yaml (1×1, blocksMovement: false, shop: general-store, spriteRef: shop — the shop sprite honestly reuses the Kenney coop tiles), and the mayor Mod spawn now carries dialogue: "mayor" so talk works out of the box. E2E: no browser/playwright harness is committed in this repo (prior rounds ran ad-hoc E2E, D27/D32) — the UI is covered by the pure ui.test.ts cases (9) + a full pnpm -r build + the existing sample-game/ws-broadcast integration suites; a browser E2E for the panels is documented future work.

Rejected candidates: (a) UI state that bypasses the engine (a client-only selectedSeed) — breaks ws symmetry and MCP/debug testability; select-seed as an intent action keeps the mechanism uniform and content-observable; (b) new MCP tools for the panels (player.buy etc.) — the existing player.intent action enum extension keeps the surface at one tool and every channel (MCP/WS/local) uniform; (c) a new content kind or schema for shop discovery — BuildingDef.shop is a single optional field on an existing kind, additive and migration-free (the spriteRef/D26 precedent); (d) snapshot changes beyond shops (e.g. carrying items/dialogues defs for the ws UI) — the protocol delta must stay minimal (D35.4 documents the ws seed-picker/seed-verification limitation instead); (e) clearing selectedSeed on a successful sow — a stale selection already degrades to the first-seed fallback, so keeping the field is the least-surprise semantics (creator policy can clear it) and keeps select-seed idempotent; (f) a StaminaSystem/skill-driven UI — stamina/skills stay D29 seeded fields; the inventory bar merely presents them; (g) an in-engine UI layer — browsers are render viewports (D8); the DOM panels are dev-layer presentation driven by the engine's intent/state surface.

Full detail in docs/api.md (§4/§7/§8), docs/mcp.md (§player/§transport), and examples/sample-game/README.md. MCP surface: 111 tools / 32 resources / 14 prompts / 20 namespaces (held; player.intent schema extended only).


D36. Mod Packaging + Browser Mod Execution + Installer Generation (closing the D30/D32 documented gaps)

Decision: Close the three long-documented packaging gaps — "Mods NOT in packaged output" (D30.4/D30-c), "browser file:///static module-import restrictions" (D30.4), and "no installer / no electron-builder in v1" (D32.6) — in one additive round: (A) Mods are packaged into the content bundle as code strings (ContentBundle.mods?: Record<modId, {manifest, code}> — additive, BUNDLE_VERSION stays 1); (B) the engine executes packaged Mod code in bundle mode with no fs access, via a base64 data:text/javascript dynamic import — the SAME mechanism in Node ≥ 20 and browsers, so the identical bundle boots in the headless core, the browser, and the Electron shell; (C) farm-pack --installer generates an electron-builder config so the desktop shell can build real installers (Linux AppImage — verified end-to-end in this repo; Windows NSIS documented config). Concretized by the implementation (2026-08-13) as:

  1. Mods as code strings in the bundle — bundledModSchema ({manifest: {id: min1, version: min1}, code: string} strict); farm-pack --mods <dir> scans the mods dir 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 via the shared entryFileOf/readModEntry helpers). A Mod with no entry or no valid manifest fails the pack loudly (per-mod messages, mirroring content compile failures — no silently-skipped Mods in a shipped game). bundle.mods is keyed by the mod's manifest id. Additive: BUNDLE_VERSION stays 1 and old bundles (no mods) load unchanged. Why code strings, not bundled files: the bundle is the single self-contained source of truth for packaged output (D30) — no separate Mod files ship, no async fetch of ../mods/... from a packaged site, and the bundle can travel as one JSON.
  2. Data-URL ESM import for browser + Node parity — ModLoader.loadFromSource(source, id, version?) dynamic-imports data:text/javascript;base64,<code>. No code-eval restrictions beyond import() itself (the engine already is the mod execution boundary — D6's hook-only contract is enforced by what the loaded module can touch: a HookContext, never engine internals). Validation and hook binding are identical to file loading (the shared bindModule step: default export must be an object, manifest {id, version} required from the mod's own export — no version fallback to the passed version, keeping file/source parity; non-function hooks and unknown hooks reported as errors while still loading; import/manifest failures → loaded: false + errors, never throw). Cache-busting for reload uses a #t= fragment on the data URL — a query string would become part of the base64 payload and corrupt it (found + fixed in this round's tests). reloadFromSources(ModSource[]) re-loads a stored set all-at-once.
  3. Bundle mode boots packaged Mods — engine.start() in bundle mode loads bundle.mods via loadFromSource when no modsDir is configured; modsDir wins when both are given (fs mods are the authoring/dev path; bundled mods are the shipped-game path). engine.reloadMods() in bundle mode re-runs reloadFromSources over the stored bundle.mods (no id form — all-at-once; engine.mods reflects bundled Mods, so the mod.* MCP introspection surface works unchanged in bundle mode — surface byte-identical). apps/dev ?bundle= mode needs no change: createEngine({bundle}) already loads bundled Mods, so the packaged site and the desktop shell get Mod behavior automatically.
  4. Installer = electron-builder config generation — farm-pack --installer (implies --desktop) extends generateDesktop: copies the site files (index.html + assets/ + bundle.json) into out/desktop/ (electron-builder packages the desktop dir as the app; main.cjs resolves the site as .., which IS the app dir once packaged — the same SITE_ROOT convention works both unpackaged and packaged), writes electron-builder.yml (appId: com.farm.<slug>, 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}), and extends package.json with electron-builder (devDep ^26) + scripts.dist/dist:win. Only Linux is buildable in this environment (electron-builder needs a Windows host for NSIS); the Windows target ships as documented config. electron-builder stays a devDependency of the generated desktop folder, not of @farm/pack — no new runtime dependency in the monorepo.
  5. Real AppImage build verified — pack:example:full (content + mods + desktop + installer) produced Pinefield Valley-0.1.0.AppImage (128 MB, valid ELF, --appimage-version runs). One environment note: this machine's TLS setup requires NODE_OPTIONS=--use-system-ca for electron-builder's AppImage-runtime download; normal machines need nothing.

Rejected candidates: (a) bundling Mod FILES into the output and importing relative paths — packaged static sites have no stable relative-module URL from bundle.json consumers, and the bundle's "single source of truth" (D30) would be split; (b) eval/new Function for source execution — dynamic import() of a data URL is the standard ESM path with real module semantics (top-level await, strict mode, real module identity + caching) in both Node and browsers; (c) allowing a manifest-version fallback in loadFromSource — file/source parity demands the same validation (manifest required); a bundle's manifest is already the mod's own export, copied at pack time; (d) an MCP pack.* namespace or Mod-authoring tools — packaging is an offline build-time tool (D30) and Mods are code, not MCP-creatable content (D6/D10); (e) shipping electron-builder in the monorepo — the generated desktop folder installs it; (f) Windows installer verification — requires a Windows host, documented as config-only (honest).

Full detail in docs/api.md (§2/§5/§9) and docs/mcp.md (change log — MCP surface unchanged: 111 tools / 32 resources / 14 prompts / 20 namespaces).


D37. Scalability — WS Snapshot Assets On-Demand + --split-assets Bundle Splitting

Decision: Close the two documented scalability gaps of the D31 asset pipeline in one additive round. (A) WS snapshots stop shipping full base64 assets: the D31 snapshot.assets field (the entire binary table, base64, in EVERY snapshot frame) is replaced by a metadata-only assetsIndex (Record<assetId, {kind, mime, size}>); clients request the bytes they actually need via a new assets-request message and the server replies per-client with a {type:"assets"} frame. (B) Bundle assets can be split out of bundle.json: farm-pack gains --split-assets, writing each asset as a sibling out/farm-assets/<id>.<ext> file and shipping a metadata-only bundle.json (assetsIndex with file paths — no base64), while the default inline mode stays byte-identical. Both fixes rest on one invariant: the engine asset table is a METADATA INDEX — bytes are a transport concern, not engine state (AssetEntry.data becomes optional; split entries carry size/file). Concretized by the implementation (2026-08-13) as:

  1. WS on-demand assets (protocol evolution, additive frames): the snapshot's assets field → assetsIndex (metadata only — {kind, mime, size}, size from assetByteSize = declared size or base64-derived byte count). New client→server {"type":"assets-request","ids":string[]} (bounded: the client's AssetCoordinator batches ≤ 64 ids per request and the server truncates over the same cap; unknown ids ignored gracefully; empty request → {type:"assets", assets:{}}). The server replies to the REQUESTING client only (request/response, not broadcast) with {type:"assets", assets} — each entry the full AssetEntry {kind, mime, data}. This is a versioned protocol change: old clients reading snapshot.assets break (documented in mcp.md's change log) — the D31 "full table in every snapshot" tradeoff (latency win, payload bloat that grows with every asset added) is replaced by metadata + on-demand bytes (payload stays O(snapshot), bytes fetched once per asset per client, cached thereafter). Rationale: the sample game's ~106 KB is small, but assets scale with creator content and every snapshot duplicates them; the render pipeline already degrades gracefully to fallbacks until textures arrive (D31's redraw-on-ready), so late-arriving bytes need no new render path.
  2. Client-side coordinator (pure, node-testable): new AssetCoordinator (packages/render/src/sync/asset-coordinator.ts) tracks wanted (ids the presentation needs), loaded (bytes arrived), and pending (in-flight request) — request dedupe (never re-requests while pending or loaded), bounded batches, new wanted ids from later snapshots requested after prior batches resolve. The wanted set is derived per snapshot by the pure snapshotAssetRefs (map.tiles values + sprite image layer srcs → images; sound.src ids → audio). connectWs feeds {type:"assets"} frames into the viewport (re-render with the merged table — the existing redraw-on-ready path) and sound.setAssets (async decode, buffers cached). connectLocal is unchanged (engine has all assets locally).
  3. Bundle splitting (--split-assets): AssetEntry.data becomes optional; new AssetIndexEntry {kind, mime, size, file?} + assetIndexEntrySchema + helpers assetByteSize/assetFileExtension. ContentBundle gains assetsIndex?: Record<assetId, AssetIndexEntry> — carried by BOTH inline and split bundles (the index invariant: bundle.json always carries the metadata index; inline mode ALSO carries assets with base64 data, split mode carries only the index with file paths). ContentLoader.loadBundle builds registry.assets from whichever is present (inline entries win when both name an id, augmented with the index's size/file). engine.assets in split mode holds metadata-only entries ({id, kind, mime, size, file} — no data). compileBundle(contentDir, modsDir?, splitAssets?) + exported splitBundleAssets produce the split form; packGame writes out/farm-assets/<id>.<ext> (bytes from the inline compile — one file per asset, ext from MIME) then the metadata-only bundle.json. Inline (default) output is byte-identical plus the additive assetsIndex — zero regression for existing static-site/desktop output.
  4. Who fetches the bytes — the render layer with an assets base: the headless engine never fetches (browser-free, zero-fs bundle mode — metadata only). The render layer gets an assetsBase: createViewport({assetsBase?}) and createSoundPlayer(factory?, assetsBase?) fetch split entries (entry.file → ${assetsBase}/${file} → blob/arrayBuffer → texture/decode, cached by id) via the pure resolveAssetUrl; inline entries decode from data: as before. The base is provided by the app: packaged static site — packGame bakes __FARM_ASSETS_BASE__ = "./farm-assets" (vite define, resolved against document.baseURI); dev ?bundle= mode — derived from the fetched bundle's directory (farm-assets/ lives next to bundle.json in dist-pack/). connectLocal needs no change (the viewport/sound hold the base).
  5. Sample-game + packaging: pnpm pack:example stays inline (regression path); new root script pnpm pack:example:split runs --split-assets; generateDesktop's installer copy includes farm-assets/ when present (and electron-builder.yml's files gains farm-assets/**). Tests: ws-broadcast +4 (metadata-only snapshot, request→response roundtrip, unknown ids ignored, empty request → empty response, 64-cap truncation), render +11 (coordinator 5, refs 3, url 3), pack +5 (flag parsing, inline index, splitBundleAssets, compileBundle split, full split packGame asserting files exist + sizes match source + split bundle boots), core +2 (loadBundle split + inline-merge). Browser E2E of split-mode rendering is not committed (prior-rounds pattern — D34/D35); the fetch plumbing is covered by unit tests + pack-output assertions + documented.

Rejected candidates: (a) keeping snapshot.assets in parallel with assetsIndex — the fix IS to stop shipping bytes per snapshot; dual fields would double the snapshot and confuse the contract (the versioned protocol change is documented instead); (b) an unlimited assets-request (no cap) — unbounded response frames reintroduce the payload problem the fix removes; 64 ids is the documented bounded batch; (c) fetching via a headless-engine API in bundle mode — the engine is browser-free and zero-fs (D30); the render layer owns byte transport (D1/D8); (d) createEngine({bundle, assetsBase}) plumbing into core — assets are presentation data (D31); the base is a RENDER-LAYER concern, provided by the app, not core engine state; (e) splitting Mod files or content defs alongside assets — only BINARY bytes scale badly; YAML defs stay in bundle.json (D30's single-source-of-truth); (f) out/assets/ for the split dir — collision with the vite build output (assets/ holds index-*.js/css); farm-assets/ is the collision-free sibling name; (g) a pack.* MCP namespace or protocol/tool changes — both fixes are offline/protocol, MCP surface stays 20/111/32/14.

Full detail in docs/api.md (§1/§2/§8/§9) and docs/mcp.md (WS transport section + change log). MCP surface unchanged: 111 tools / 32 resources / 14 prompts / 20 namespaces.


D38. Animation State Machines (content kind) + Render-Layer Smooth Movement

Decision: Close the documented "movement animation" gap (D27's frame-clock exists, but there is no state machine switching idle/walk/dance sprites and no tweening — movement is instant grid-jump, D25/D22) with two additive, authority-preserving mechanisms: (A) an animation content kind — a state machine over sprite defs ({id, initial, states: [{id, spriteRef, fps?, loop?}], transitions?: [{from, to, on?}]}, zod strict with the statemachine-kind superRefine cross-checks), driven by an 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 because the set-state value schema already accepts it, so no language change is needed), with the render layer falling back to motion inference ("walk" while moving, "idle" when still, else initial) — no engine movement-semantics change; (B) smooth movement as a render-layer tween — consecutive known tile-coordinate changes (player/npc/animal entities) are interpolated over MOVE_DURATION_MS (250 ms, easeOutQuad) in the viewport's rAF loop, while the engine stays the authoritative instant grid-jump (IntentSystem.move/MovementSystem.moveTo untouched — every viewport interpolates the same coords, so multi-viewport sync is consistent by construction). Concretized by the implementation (2026-08-13) as:

  1. animation content kind + animation.* MCP namespace: animationSchema in packages/core/src/schema/animation.ts (AnimationDef/AnimationState/AnimationTransition; superRefine: duplicate state ids rejected, initial must be a declared state, transition from/to must be declared states — mirroring stateMachineSchema); registered in contentSchemas/contentDirNames (dir content/animations/); the MCP namespace animation.* mirrors sprite.*/sound.*/particle.*/shader.* exactly — validate/create/update/list/get + animation://catalog/animation://{id} + animation.create-prompt (5 tools + 2 resources + 1 prompt — surface 20→21 namespaces / 111→116 tools / 32→34 resources / 14→15 prompts). The WS snapshot frame gains an additive animations: AnimationDef[] catalog (registry list at snapshot time; render WsSnapshotFrame/RenderSnapshot optional) — same pattern as particles/shaders/shops.
  2. State resolution is content-driven with render-layer inference — the render layer (no engine system, no hook): an entity whose spriteRef matches an animation def id uses that def (the sample game's player spriteRef: "player" matches content/animations/player.yaml — no entity change needed); the state is (1) the entity's explicit anim field when it names a declared state, else (2) motion inference (walk while tweening / idle when settled), else (3) initial; the resolved state's spriteRef sprite draws at the state's fps (default DEFAULT_ANIMATION_FPS 10) and loop (default true; false plays once then holds the last frame — the new pure frameAtTimeOrHold in sprite-draw). A spriteRef matching NO animation def renders exactly as before (additive, zero regression). The behavior language can force a state any time (set-state player.anim "dance") and clear it back to motion inference (set-state player.anim null).
  3. Smooth movement is a render-layer tween over the existing rAF loop — pure packages/render/src/tween.ts (tweenPosition/tweenTile easeOutQuad, motionState/animState motion inference, resolveAnimationState/animationState state helpers, MOVE_DURATION_MS = 250) + viewport wiring: a per-entity prevPositions map records last-known tile coords; a coordinate change starts a {from, to, start} tween (pruned on settle / entity removal); animTick advances and redraws while any tween is active; drawn entities use an interpolated fractional-tile copy (so sprite drawing and shape fallbacks both glide), and entity labels follow the tweened position. Buildings/plots/static entities are never tweened (they carry coords but no movement semantics). No protocol change — entity coords were already in the snapshot.
  4. Sample-game content stays honest: content/animations/player.yaml (idle→player, walk→player-walk fps 8, optional transitions idle↔walk) + a new programmatic 2-frame placeholder walk sprite content/sprites/player-walk.yaml (body/head/hat layers shifted 1 px per frame — placeholder art, documented). Chicken/mayor walk sprites are deliberately NOT fabricated (D34-e precedent: the mechanism is unit-tested + documented; single-frame art would be dishonest). No animation def for the mayor → it renders as today (tweened orange dot) — the walk/idle inference defaults are render-layer, never engine-hardcoded.
  5. Tests: core animation-schema.test.ts (10 — valid def, minimal def, bad initial / duplicate state ids / undeclared transition / non-positive fps / strict keys / loader load + per-item error, plus set-state null clearing via runActions), mcp animation-namespace.test.ts (9 — mkdtemp lifecycle create/validate/update/list/get, duplicate overwrite, resources, prompt), render tween.test.ts (15 — tweenPosition at 0/50%/100% + clamps, easeOutQuad math, tweenTile, motionState, animState explicit-vs-inferred + null-clear, resolveAnimationState/initial fallback, frameAtTimeOrHold loop clamp) + sprite-draw.test.ts untouched; client-surface counts 116/34/15/21 + subsystem/tool/resource/prompt sorted lists + an animation.* lifecycle case; ws-broadcast asserts the snapshot animations catalog; sample-game content assertions gain animation: ["player"] + player-walk. E2E (playwright mid-tween screenshots) is NOT committed — timing-flaky; smooth movement is verified by the pure math tests + pnpm -r build (prior-rounds pattern, D34/D35/D37).

Rejected candidates: (a) an engine-side AnimationSystem in the headless core — animation is pure presentation (sprite selection + motion inference) with no simulation consequence; the render layer owns it (D1/D8/D34 "no systems for presentation"), and a core system could not help anyway (the engine has no frame loop — D8); (b) enforcing hard transitions at runtime — the state machine is content freedom (a content override via anim may jump anywhere; the transitions graph is an authoring aid only); enforcement would break content freedom and add a second source of truth (D20 precedent); (c) adding a new behavior-language action just to clear anim — set-state player.anim null already works (the set-state value schema accepts null — requiredValueSchema rejects only undefined), so no language change is needed; (d) tweening in the engine (smooth moveTo with duration) — breaks the D22/D25 authoritative-instant-grid-jump contract, breaks every existing movement test, and would race multi-viewport clients; render-layer interpolation of the same authoritative coords is deterministic and consistent; (e) tweening buildings/plots — they carry coords but no movement semantics (never move); (f) fabricating walk art for the chicken/mayor — placeholder single-frame walk is dishonest; the mechanism is demonstrated on the player only (D34-e precedent); (g) a player.anim-style schema field or hook — anim is a plain open-typed entity field (the label/spriteRef precedent), snapshot-able and save.*-covered automatically; no new hook (the behavior language and motion inference are the two drivers, both already in place).

Full detail in docs/api.md (§7/§8) and docs/mcp.md (§animation/§change-log). MCP surface: 116 tools / 34 resources / 15 prompts / 21 namespaces (+5 tools +2 resources +1 prompt); content kinds 16→17; tests 692→727 (+35).


D39. Engine Rename — "farm" Branding → "nEngine"

Decision: The engine is renamed from the "farm" branding to nEngine across the entire repository. The rename covers the npm package names (@farm/core → @nengine/core, @farm/mcp → @nengine/mcp, @farm/render → @nengine/render, @farm/pack → @nengine/pack, and the app packages @farm/dev/@farm/mcp-server → @nengine/dev/@nengine/mcp-server), the root package name (farm-engine → nengine), the MCP server identity (farm-mcp-server → nengine-mcp-server, server.info.engine: "farm-engine" → "nengine"), the file-format identifiers, the CLI name, the Vite build-time defines, the runtime env vars, and the split-assets output directory. Content data is NOT renamed: the sample game's map id "farm", the "farm-tiles" tileset ref, the game name "Pinefield Valley", and all dialogue/asset content stay exactly as they are — those are creator content, not engine branding. Historical D1–D38 changelog text keeps its original "farm" references (append-only principle).

Rationale: The user renamed the engine to "nEngine" ("此引擎改名叫nEngine"). The "farm" prefix was the engine's working brand since its inception; the rename is cosmetic (identity/branding) and touches no engine mechanism, system behavior, protocol frame, or MCP surface shape. It IS a breaking change for two on-disk format identifiers (below) and for tool/CLI/env names — documented here so downstream consumers can migrate.

Concretized as:

  1. Package/identity renames: @farm/* → @nengine/* across root + all package package.json names/deps (pnpm workspaces resolve by name — a pnpm install re-links the symlinks), root script filters (--filter @nengine/dev, --filter @nengine/mcp-server), the MCP stdio server identity { name: "nengine-mcp-server", version: "0.1.0" }, and the server.info/server://status engine id engine: "nengine" (shape {engine, version, content, subsystems, time} unchanged). server.orient + the creation-namespace prompts now address "the nEngine".
  2. Format identifiers — BREAKING (v1 era, no real user data): the save format literal "farm-save" → "nengine-save" (SAVE_FORMAT, WorldSnapshot.format, the save.* authoring prompt) and the bundle format literal "farm-bundle" → "nengine-bundle" (BUNDLE_FORMAT, ContentBundle.format, loadBundle's structural-error message). CURRENT_SAVE_VERSION stays 1 and BUNDLE_VERSION stays 1. Old farm-save/farm-bundle files are NOT accepted — this is a deliberate breaking change, acceptable because the engine is pre-release (v1 era, no real user data in the wild).
  3. CLI + build-time + env renames: the packaging CLI bin farm-pack → nengine-pack (USAGE text + error prefixes follow); the Vite defines __FARM_BUNDLE_DEFAULT__ → __NENGINE_BUNDLE_DEFAULT__ and __FARM_ASSETS_BASE__ → __NENGINE_ASSETS_BASE__ (apps/dev bundle-default.d.ts, main.ts, apps/pack buildViteSite, vitepacktest.mjs); the runtime env vars FARM_CONTENT_DIR/FARM_MODS_DIR/FARM_WS_PORT → NENGINE_CONTENT_DIR/NENGINE_MODS_DIR/NENGINE_WS_PORT (CLI flags --content-dir/--mods-dir/--ws-port unchanged); the split-assets output dir farm-assets → nengine-assets (pack output, __NENGINE_ASSETS_BASE__ default "./nengine-assets", installer copy + electron-builder.yml files list, bundle assetsIndex.file paths, the electron-builder appId prefix com.farm. → com.nengine.).
  4. What did NOT change: content (map id "farm", tileset "farm-tiles", game name "Pinefield Valley", dialogue text, sample-game assets), the MCP surface (21 namespaces / 116 tools / 34 resources / 15 prompts), all system behavior, all protocol frames (WS/input/intent), the CLI flag names, and the historical D1–D38 changelog text (append-only). The default desktop product name stays "Farm Game" (a creator-facing default game title, not engine branding).

Operating Principles (meta-decisions)

  1. MCP-first interface design (decision-maker's directive): when designing any interface, design the MCP surface first (tools/resources/prompts), then derive the engine API from it. Engine internals exist to serve the MCP surface.
  2. Living API docs (decision-maker's directive): "要及时更新引擎的api文档" — API docs update in the same change as the interface change. A change without its doc update is incomplete.
  3. Mechanisms over policies; declarative data language over code; Hooks over deep imports.
  4. Type safety: never suppress errors (as any, @ts-ignore, @ts-expect-error).