Exact source used by this build. SHA-256: fe7e132912e3736802d4a79eb29159f01cc49c151c303e1e36355c3c594c7d20.

1 # Extending Tamarack
2 
3 Features own their folders. Coordinate the small registry edits once when assigning work; agents can then implement separate systems, domains and tests without sharing implementation files. Do not add gameplay branches to `FarmWorld`, the protocol envelope, or the World/UI scene coordinators.
4 
5 ## Ownership and registries
6 
7 ```
8 packages/sim/src/
9   systems/index.ts             system imports and registration list
10   systems/registry.ts          generic contracts, validation, dispatch and hooks
11   systems/<system>/           state defaults, intents, handlers and hooks
12   examples/counter.ts          compiled extension example
13 packages/content/
14   data/<domain>/*.json         content owned by a domain
15   data/maps/*.json             Tiled source maps
16   src/domains/index.ts         validator registration list
17   src/domains/<domain>/        schema.ts and index.ts (plus domain helpers)
18 apps/client/src/
19   core/                       generic session and registration contracts
20   scenes/                     Boot, Title, World, UIOverlay
21   systems/index.ts            client registration list
22   systems/<system>/           panels, actions, input drivers and renderers
23 apps/server/src/
24   configuration.ts            inject content into simulation rules
25   migration.ts                preserve old saves while hydrating defaults
26   index.ts                    transport, scoped updates and generic persistence
27 ```
28 
29 The sim depends on Zod, never the browser, Phaser, Cloudflare, wall-clock time or unseeded randomness. The server supplies elapsed time. Systems receive registered domain data through `rules.content?.[domainId]`. Each domain publishes a `data` value and the server automatically injects `CONTENT_DATA`; a new content-backed system needs no shared rules-type or server configuration edits. Parse or narrow the domain payload with a schema or type owned by that system. P0 crop and weather fields remain compatibility inputs.
30 
31 ## Add a simulation system
32 
33 1. Create `packages/sim/src/systems/<id>/index.ts` and export a `defineSystem({...})` object satisfying `SimSystem`.
34 2. Return fresh objects from `worldDefaults` and `playerDefaults`. Store new state in `state.systems[id]` and `player.systems[id]`; area-owned metadata can use `area.systems[id]`. Keep it JSON serializable. Update immutably and increment `revision` when a handler changes state. Never mutate an incoming state or another system's slice.
35 3. Declare `intents` and `events`, and any `onOvernight`, `onClockTick`, `onPlayerAdded`, or `onPresenceChange` hooks. An unused hook can be omitted. Return `{state, events}` or `{state, events: [], error}`. Hooks execute in registry order and receive the state returned by the previous hook.
36 4. Import the object and add one entry to the `systems` list in `systems/index.ts`. This is the only shared registration file. The inferred intent union, wire validation and dispatch update automatically; no edits to `types.ts`, `protocol.ts`, reducers or the DO are needed.
37 5. Test the system against an isolated `createSystemRegistry([yourSystem])` before enabling it in the live registry.
38 
39 The existing P0 fields remain compatible but are initialized and owned by their respective system. New systems should use the namespaced slices. `initializeSystemState` fills absent slices on save load without replacing saved values; a schema change within an existing slice needs an explicit migration.
40 
41 Pass the configured `SimRules` as the third argument to `addPlayer` so
42 `onPlayerAdded` hooks receive registered content on the first join. Shared sleep
43 readiness lives in the energy world slice and survives area scoping. The current
44 players share the starter home's bed POI; sleep validates that authored bed and
45 the player's location before accepting confirmation.
46 
47 Calendar runs first overnight, then energy restoration and crop growth, then the new day's weather. Growth reads the previous day's watering, removes crops outside their new season, and clears old watering. Weather then rolls from the persisted seeded RNG and waters outdoor soil for the new day. Place new hooks according to the data they need. Use `context.overnight(...)` when a system must request the shared overnight pipeline.
48 
49 Elapsed time has one entry point, `advanceClock`. During connected play it calls
50 `onElapsedTime` for each real-time segment, splitting at calendar ticks, then
51 `onClockTick` once per complete ten-minute tick. Combat uses the segments;
52 machines use the shared machine-minute clock, and tick-based effects use the
53 tick's elapsed duration. A system must not charge the same timer in both hooks.
54 With nobody connected, only `onOfflineElapsed` runs: crafting machines advance
55 through their virtual machine clock while calendar, crops, combat and player
56 effects stay frozen. Pause suppresses both elapsed-time paths. Systems request
57 active simulation cadence through `updateIntervalMs`; the registry chooses the
58 smallest positive finite interval, bounded to at least 100 ms. This cadence is
59 independent of the server's persistence/checkpoint schedule. Paused or fully
60 offline worlds release fast cadence requests. A calendar-freezing event exposes
61 `clockPauseRemainingMs` so one large elapsed update splits exactly at its expiry;
62 its activities still receive each millisecond once. `weatherOverride` lets a
63 system supply the day's required weather before soil watering and animal bowl
64 preview, while weather retains ownership of its seeded roll.
65 
66 ### Tiny compiled example
67 
68 The actual source is [`packages/sim/src/examples/counter.ts`](../packages/sim/src/examples/counter.ts). This shortened system is enough for a new world state slice and an intent:
69 
70 ```ts
71 import { z } from "zod";
72 import { defineIntent, defineSystem } from "../registry";
73 
74 export const counterSystem = defineSystem({
75   id: "counter",
76   worldDefaults: () => ({ count: 0 }),
77   playerDefaults: () => ({ contributions: 0 }),
78   events: ["counterChanged"],
79   intents: [defineIntent(
80     "increment",
81     z.object({ type: z.literal("increment"), amount: z.number().int().min(1).max(5) }),
82     ({ state, playerId }, intent) => {
83       const count = (state.systems.counter as { count: number }).count + intent.amount;
84       return {
85         state: { ...state, revision: state.revision + 1,
86           systems: { ...state.systems, counter: { count } } },
87         events: [{ type: "counterChanged", playerId, data: { count } }],
88       };
89     },
90   )],
91 });
92 ```
93 
94 Put that code in `systems/counter/index.ts`, import it in `systems/index.ts`, and append `counterSystem` to `systems`. The checked-in example additionally updates the per-player contribution slice. [`registry.test.ts`](../packages/sim/test/registry.test.ts) compiles the real example, rejects invalid intents, dispatches it, verifies both slices, and round-trips its patch. The example is deliberately absent from the live game's registry.
95 
96 ## Add an intent or event
97 
98 Add `defineIntent(name, zodSchema, handler, optionalPolicy)` inside the owning system's `intents` array. Use a unique literal `type` matching `name`. Handler input is inferred from that schema. The generic dispatcher validates first and checks connected, paused and sleeping state.
99 
100 A policy can allow an intent during pause or sleep. `timing: "your-budget-name"` asks the server to maintain a separate elapsed-time budget, capped at one second. Return `timingCostMs` to charge the actual work; without it, an accepted intent consumes the available budget. Rejected intents consume none. A policy may supply `timingBufferMs` once at initialization to absorb arrival jitter; movement uses 100 ms and charges actual source-area distance, including partial travel to a warp. Repeated packets cannot regenerate that allowance. Movement uses this for speed validation. Do not add an intent-name check to the server.
101 
102 Use `persistence: "batched"` for frequent intent progress with no lasting outcome.
103 Mark visual/progress events `transient: true`; the server still broadcasts them,
104 but does not journal or log each frame. A durable outcome event (catch, kill,
105 loot, purchase) causes pending state to journal immediately even for a batched
106 intent. Ordinary intents/events retain immediate durability by default.
107 
108 Declare event names in the system's `events` list. Events support `playerId`, `areaId`, `message`, JSON `data`, `checkpoint` and `snapshot` metadata. Set `areaId` for area-local events; omitted area IDs are world-wide. `checkpoint: true` requests durable compaction. Player area changes automatically trigger replacement snapshots; ordinary changes produce scoped patches. State, not event text, is authoritative.
109 
110 ## Add a content domain
111 
112 Create `data/<domain>/<file>.json`, `src/domains/<domain>/schema.ts`, and `index.ts`. Keep content values in JSON. Parse the JSON with the schema, export the typed values, and export a `ContentDomain` with a unique ID and `validate()` method. The validator may also check references or invariants.
113 
114 ```ts
115 // src/domains/messages/schema.ts
116 import { z } from "zod";
117 export const MessagesSchema = z.array(z.object({ id: z.string(), text: z.string() }));
118 ```
119 
120 ```ts
121 // src/domains/messages/index.ts
122 import json from "../../../data/messages/messages.json";
123 import { MessagesSchema } from "./schema";
124 import type { ContentDomain } from "../types";
125 export const MESSAGES = MessagesSchema.parse(json);
126 export const messagesDomain: ContentDomain = {
127   id: "messages", data: MESSAGES, validate() { MessagesSchema.parse(json); },
128 };
129 ```
130 
131 Import `messagesDomain` and append it to `CONTENT_DOMAINS` in `src/domains/index.ts`. `bun run --cwd packages/content validate` now runs it, and the server automatically exposes its `data` as `rules.content.messages`. Consumers can import a domain through the package's `@tamarack/content/domains/<domain>` export; adding a convenience root export is optional. Add a domain test for invalid references and required fields. Do not extend the legacy monolithic schema facade for new domains.
132 
133 ## Add a client panel, action or renderer
134 
135 Create `apps/client/src/systems/<id>/index.ts` exporting a `ClientSystem`. Import and add it to `clientSystems` in `systems/index.ts`. A panel declares its corner and owns its DOM and update logic:
136 
137 ```ts
138 import type { ClientSystem } from "../../core/types";
139 export const counterClientSystem: ClientSystem = {
140   id: "counter",
141   panels: [{
142     id: "counter-value", slot: "top-right",
143     create(session) {
144       const element = document.createElement("div");
145       return { element, update() {
146         const counter = session.world?.systems.counter as { count: number } | undefined;
147         element.textContent = String(counter?.count ?? 0);
148       } };
149     },
150   }],
151 };
152 ```
153 
154 Slots are `top-left`, `top-right`, `bottom-left` and `bottom-right`; the overlay applies phone safe-area spacing. A panel may implement `destroy` to remove its own listeners. Avoid fixed viewport coordinates inside a panel.
155 
156 For inputs, register an `actions` entry with ID, label, optional key, `placement` (`hotbar` or `button`) and `run(session, tile)`. Send an intent with `session.send(...)`; never change authoritative state from a button. An optional `count` displays inventory. An `inputs` driver provides `update` and `destroy` for continuous controls. A system's `interact` hook can claim contextual targets before the selected tool.
157 
158 A renderer factory receives the Phaser scene and session and returns `redraw`, optional `update`, and `destroy`. Create graphics owned by that renderer, redraw from the current scoped snapshot, and remove listeners and graphics on destroy. Keep Phaser imports out of pure geometry helpers so they can be tested in Bun. The World scene manages renderer lifecycle; it does not need to know which features are registered.
159 
160 ## Add an area
161 
162 1. Author an orthogonal, finite Tiled map with 16×16 tiles under `data/maps/<id>.json`. Use an embedded tileset with ordinary unflipped GIDs; external TSX/TSJ references are not supported by this loader. Use the existing original placeholder tileset or add credited CC0 art.
163 2. Include tile layers named `ground`, `decor`, `collision` and `tillable`, each with exactly width × height numeric GIDs. Zero means empty; nonzero collision/tillable cells are true. Map properties include string `areaId` and boolean `outdoor`.
164 3. Include an object layer named `objects`. Add exactly one `spawn` object named `default`. Object positions and dimensions are pixels; the loader converts them to tile coordinates.
165 4. A `warp` rectangle needs unique name/ID plus properties `targetAreaId`, `targetX`, `targetY`, `locked`, `label` and `kind`. Destination coordinates are **tiles**, not pixels. Use `path`, `door`, `gate`, `rail` or `boat` as appropriate. Keep destination tiles walkable and outside all warp rectangles to prevent bounce loops. Add a return warp in the neighboring map. Locked routes return “Repairs needed” and do not move the player.
166 5. Add optional `landmark` objects with `kind: home` and bounds snapped to 16-pixel tiles, and named `poi` objects for buildings, doors, beds and other targets. The mines domain references the mountain mine POI by ID and derives its entrance position from that map object; generated mine interiors are owned by the mines system.
167 6. Import and register the JSON in `src/domains/maps/index.ts`'s `TILED_MAPS` list. Add metadata (matching dimensions, working name, `ground` string, `tillable` boolean, outdoor/locked flags and clock rate) to `data/farm/farm.json`.
168 7. Add spring, summer, autumn and winter entries in `data/seasons/palettes.json`, each containing `tint`, `groundColor`, `foliageColor` and `groundCover`. Every outdoor area needs all four entries. Winter uses `snow` outside the rainforest; rainforest entries map the calendar to `wet` and `dry` ground cover. The renderer reads this data and can later swap real ground-cover art using the same keys.
169 8. Run the content validator and add a loader or connection test. No server class, protocol, scene or renderer edits are required to add another map using existing tile/cover kinds.
170 
171 The shared loader validates dimensions, GIDs, object bounds, spawns, destinations and bounce loops. Runtime movement checks every segment for collision and warps rather than accepting teleports. The server sends only the recipient's area and same-area player records. Replacing a snapshot on transition removes old area state and player presence.
172 
173 Water tiles carry a tileset `waterTag` property: `lake` (including the farm pond),
174 `river`, `ocean`, `mountain_lake`, `rainforest_river` or `bog`. Fishing derives
175 habitat coverage and water GIDs from these map tags; do not duplicate water
176 coordinates in the fishing catalog. Different GIDs in one map may have different
177 tags. Bridges use a dry GID so a painted crossing never becomes fishable water.
178 
179 ## Validation before handoff
180 
181 ```sh
182 bun run check
183 bun run --cwd apps/client build
184 ```
185 
186 The full check includes the compiled counter example, registry contracts, map loading/collision/warps, seasonal palette selection, seeded weather and rain watering, and two real WebSocket clients walking from farm to town. Use `bun test packages/sim`, `bun test packages/content` or `bun test apps/client` during local feature work; run the full check after integration. Do not deploy, log in to Wrangler, touch credentials or push as part of this workflow.
187 
188 ### JSON-only item and crop additions
189 
190 Add an object or an array of objects in any `data/items/**/*.json` or
191 `data/crops/**/*.json` file. `bun run content:prepare` deterministically combines
192 these files into ignored `packages/content/generated/` catalogs. Install,
193 typecheck, content validation, client build/dev and server dev run this step;
194 run it explicitly before a direct `bun test` after changing JSON files. This
195 build-time preparation keeps filesystem access out of the browser, Worker and
196 pure simulation, and requires no source import for each new item/crop file.
197 Item ids must be unique; seeds reference crops with `cropId`, and crop produce
198 uses the crop id. `validateItemCatalog` enforces this equal-ID invariant;
199 retaining `cropId` alone does not make a renamed produce item valid.
200 A domain producing or consuming items must validate its ids
201 with `validateItemReferences` (or its own domain-specific validator) when the
202 domain loads. `legacyIds` provides one-time inventory aliases for retired ids.
203 
204 Inventory helpers own all slot updates and refresh the legacy quantity view.
205 Use `addItem`, `takeSlot` and `removeItem` instead of mutating either inventory
206 representation. Check `remaining` before committing atomic purchases/harvests;
207 ground pickup keeps leftovers in the world. Tool progression is per player;
208 physical tools can be stored in chests, but cannot be dropped, trashed or sold.
209